diff options
183 files changed, 3181 insertions, 1631 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 440d52904..af5264279 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "8.0.5", + "version": "8.0.7", "commands": [ "dotnet-ef" ] diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml index c2acaaccb..31ae50263 100644 --- a/.github/ISSUE_TEMPLATE/issue report.yml +++ b/.github/ISSUE_TEMPLATE/issue report.yml @@ -1,59 +1,131 @@ name: Issue Report description: File an issue report -title: "[Issue]: " labels: [bug, triage] body: - type: markdown + id: introduction attributes: value: | - Thanks for taking the time to report an issue. Before submitting a report, please do the following: - 1. Please head to our forum or chat rooms and troubleshoot with volunteers if you haven't already. Links can be found here: https://jellyfin.org/contact/ - 2. Please search the bug tracker for similar issues. If you do find one, please comment there instead of opening a new bug report. - 3. If you decide to open a new report, please provide as much detail as possible. - 4. Please **ONLY** report **ONE** issue per report. If you are experiencing multiple issues, please open multiple reports. + ### Thank you for taking the time to report an issue! + Please keep in mind that Jellyfin is a [free and open-source](https://jellyfin.org/docs/general/about) project, made up entirely and exclusively of **volunteers** who donate their free time to the project. + - type: checkboxes + id: before-posting + attributes: + label: "This issue respects the following points:" + description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment. + options: + - label: This is a **bug**, not a question or a configuration issue; Please visit our forum or chat rooms first to troubleshoot with volunteers, before creating a report. The links can be found [here](https://jellyfin.org/contact/). + required: true + - label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_. + required: true + - label: I'm using an up to date version of Jellyfin Server stable, unstable or master; We generally do not support previous older versions. If possible, please update to the latest version before opening an issue. + required: true + - label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct). + required: true + - label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one. + required: true + - type: markdown + id: preliminary-information + attributes: + value: | + ### General preliminary information + + Please keep the following in mind when creating this issue: + + 1. Fill in as much of the template as possible. When you are unsure about the relevancy of a section, do include the information requested in that section. Only leave out information in sections when you are completely sure about it not being relevant. + 2. Provide as much detail as possible. Do not assume other people to know what is going on. + 3. Keep everything readable and structured. Nobody enjoys reading poorly written reports that are difficult to understand. + 4. Keep an eye on your report as long as it is open, your involvement might be requested at a later moment. + 5. Keep the title short and descriptive. The title is not the place to write down a full description of the issue. + 6. When deciding to leave out information in a field, leave it blank and empty. Avoid writing things such as `n/a` for empty fields. - type: textarea - id: what-happened + id: bug-description attributes: - label: Please describe your bug - description: Also tell us, what did you expect to happen? + label: Description of the bug + description: Please provide a detailed description on the bug you encountered, in a readable and comprehensible way. placeholder: | - The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful. - If you are using an old release of Jellyfin, please also explain why. + After upgrading to version x.y.z of Jellyfin, the "login disclaimer" is showing incorrect text. It appears to me that it is appending the server name to the end of the login disclaimer, and showing that to a user. It might be a regression from pull request x. I have tried rebooting my host as well as my container multiple times. I tested this functionality on different clients, and it happens to all the tested clients (client x, y, z), that support the login disclaimer functionality. This makes me believe it is a server side issue. validations: required: true - type: textarea id: repro-steps attributes: - label: Reproduction Steps + label: Reproduction steps + description: Reproduction steps should be complete and self-contained. Anyone can reproduce this issue by following these steps. Furthermore, the steps should be clear and easy to follow. + placeholder: | + 1. Sign in on the Jellyfin web client, with an admin account, using a browser of your choice. + 2. Navigate to the dashboard. + 3. Select "general". + 4. Change the login disclaimer to something like "I am a cool disclaimer!" + 5. Save the settings. + 6. Sign out. + 7. Make sure you are on the sign in screen. Otherwise, navigate to the sign in screen manually. + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label: What is the current _bug_ behavior? + description: Write down the incorrect behavior that currently happens after following the reproduction steps. + placeholder: | + The login disclaimer on the sign in screen has the server name appended to the text. The text shown is: "I am a cool disclaimer!jellyfinserver". + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: What is the expected _correct_ behavior? + description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps. placeholder: | - 1. In this environment... - 2. With this config... - 3. Run '...' - 4. See error... + The login disclaimer on the sign in screen should only show the configured text. The text that should be shown is: "I am a cool disclaimer!". validations: required: true - type: dropdown id: version attributes: - label: Jellyfin Version - description: What version of Jellyfin are you running? + label: Jellyfin Server version + description: What version of Jellyfin are you using? options: - - 10.9.0 - - 10.8.13 - - 10.8.12 or older (please specify) - - Weekly unstable (please specify) - - Master branch + - 10.9.7 + - Master + - Unstable + - Older* validations: required: true - type: input - id: version-other + id: version-master + attributes: + label: "Specify commit id" + description: Fill in this field in case the option 'master' is selected. Provide the commit id it was built on. + placeholder: | + 610e56baafc3011e1bfa043bdabb567bda0c2ab0 + - type: input + id: version-unstable + attributes: + label: "Specify unstable release number" + description: Fill in this field in case the option 'unstable' is selected. Provide the unstable release number. + placeholder: | + 2024050906 + - type: input + id: version-older attributes: - label: "if other:" - placeholder: Other + label: "Specify version number" + description: Fill in this field in case the option 'older' is selected. Provide the version number. + placeholder: | + x.y.z + - type: input + id: build-version + attributes: + label: "Specify the build version" + description: Please provide the build version that is shown in the dashboard. + validations: + required: true - type: textarea + id: environment-information attributes: label: Environment description: | + Accurately fill in as much environment details as possible. If a certain environment field is not shown in the template below, but you consider useful information, please include it. Examples: - **OS**: [e.g. Debian 11, Windows 10] - **Linux Kernel**: [e.g. none, 5.15, 6.1, etc.] @@ -88,21 +160,22 @@ body: validations: required: true - type: markdown + id: general-information-logs attributes: value: | - When providing logs, please keep the following things in mind. - 1. **DO NOT** use external paste services. + When providing logs, please keep the following things in mind: + 1. **DO NOT** use external paste services. If logs are too large to paste into the field, upload them as text files. 2. Please provide complete logs. - - For server logs, include everything you think is important plus *10 lines before and after* + - For server logs, ensure to capture all relevant information, encompassing both the events leading up to and following the occurrence of the issue. Typically, providing 10 *lines preceding and succeeding* the problem should be adequate. - For ffmpeg logs, please provide the entire file unmodified. - 3. Please do not run logs through any translation program. Especially beware if your browser translates pages by default. + 3. Please do not run logs through any translation program. We exclusively accept raw, untranslated logs. Particularly exercise caution if your browser automatically translates pages by default. + - Do not forget to censor out personal information such as public IP addresses. 4. Please do not include logs as screenshots, with the only exception being client logs in browsers. - type: textarea - id: logs + id: jellyfin-logs attributes: label: Jellyfin logs description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs. - placeholder: For playback issues, browser/client and FFmpeg logs may be more useful. render: shell validations: required: true @@ -110,24 +183,20 @@ body: id: ffmpeg-logs attributes: label: FFmpeg logs - description: Please copy and paste recent FFmpeg log output. This can be found in Dashboard > Logs > FFmpeg*.log. - placeholder: This field is mandatory for debugging hardware transcoding issues. It's important to include the specific codec details. If no FFmpeg logs appear, the file was Direct Played and did not use FFmpeg. + description: Relevant FFmpeg log output. This can be found in Dashboard > Logs > FFmpeg*.log. This field is considered mandatory for transcoding related issues. It's also important to include the specific codec details. render: shell - type: textarea - id: browserlogs + id: browser-logs attributes: - label: Please attach any browser or client logs here - placeholder: Access browser logs by using the F12 to bring up the console. Screenshots are typically easier to read than raw logs. For clients such as Android or iOS, please see our documentation. + label: Client / Browser logs + description: Access browser logs by using the F12 to bring up the console. Screenshots are typically easier to read than raw logs. For clients such as Android or iOS, please see our documentation. - type: textarea id: screenshots attributes: - label: Please attach any screenshots here - placeholder: Images can be pasted directly into the textbox and will be hosted by github. - - type: checkboxes - id: terms + label: Relevant screenshots or videos + description: Attach relevant screenshots or videos related to this report. + - type: textarea + id: additional-information attributes: - label: Code of Conduct - description: By submitting this issue, you agree to follow our [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct) - options: - - label: I agree to follow this project's Code of Conduct - required: true + label: Additional information + description: Any additional information that might be useful to this issue. diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 9026c5fae..c6ea1d7ca 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -20,18 +20,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - name: Setup .NET - uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 + uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 with: dotnet-version: '8.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 + uses: github/codeql-action/init@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 + uses: github/codeql-action/autobuild@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@b7cec7526559c32f1616476ff32d17ba4c59b2d6 # v3.25.5 + uses: github/codeql-action/analyze@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12 diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index d7f1e3076..54a061556 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -16,18 +16,18 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET - uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 + uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 with: dotnet-version: '8.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@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 with: name: openapi-head retention-days: 14 @@ -41,7 +41,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -55,13 +55,13 @@ jobs: 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@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 + uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 with: dotnet-version: '8.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@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4 with: name: openapi-base retention-days: 14 @@ -80,12 +80,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: openapi-base path: openapi-base @@ -101,11 +101,24 @@ jobs: - id: read-diff name: Read openapi-diff output run: | + # Read and fix markdown body=$(cat openapi-changes.md) - body="${body//'%'/'%25'}" - body="${body//$'\n'/'%0A'}" - body="${body//$'\r'/'%0D'}" - echo ::set-output name=body::$body + # Write to workflow summary + echo "$body" >> $GITHUB_STEP_SUMMARY + # Set ApiChanged var + if [ "$body" != '' ]; then + echo "ApiChanged=1" >> "$GITHUB_OUTPUT" + else + echo "ApiChanged=0" >> "$GITHUB_OUTPUT" + fi + # Add header/footer for diff comment + echo '<!--openapi-diff-workflow-comment-->' > openapi-changes-reply.md + echo "<details>" >> openapi-changes-reply.md + echo "<summary>Changes in OpenAPI specification found. Expand to see details.</summary>" >> openapi-changes-reply.md + echo "" >> openapi-changes-reply.md + echo "$body" >> openapi-changes-reply.md + echo "" >> openapi-changes-reply.md + echo "</details>" >> openapi-changes-reply.md - name: Find difference comment uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0 id: find-comment @@ -115,22 +128,15 @@ jobs: body-includes: openapi-diff-workflow-comment - name: Reply or edit difference comment (changed) uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 - if: ${{ steps.read-diff.outputs.body != '' }} + if: ${{ steps.read-diff.outputs.ApiChanged == '1' }} with: issue-number: ${{ github.event.pull_request.number }} comment-id: ${{ steps.find-comment.outputs.comment-id }} edit-mode: replace - body: | - <!--openapi-diff-workflow-comment--> - <details> - <summary>Changes in OpenAPI specification found. Expand to see details.</summary> - - ${{ steps.read-diff.outputs.body }} - - </details> + body-path: openapi-changes-reply.md - name: Edit difference comment (unchanged) uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 - if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }} + if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }} with: issue-number: ${{ github.event.pull_request.number }} comment-id: ${{ steps.find-comment.outputs.comment-id }} @@ -142,10 +148,7 @@ jobs: publish-unstable: name: OpenAPI - Publish Unstable Spec - if: | - github.event_name != 'pull_request_target' && - ${{ ! startsWith(github.ref, 'refs/tags/v') }} && - contains(github.repository_owner, 'jellyfin') + if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }} runs-on: ubuntu-latest needs: - openapi-head @@ -155,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@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: openapi-head path: openapi-head @@ -207,9 +210,7 @@ jobs: publish-stable: name: OpenAPI - Publish Stable Spec - if: | - startsWith(github.ref, 'refs/tags/v') && - contains(github.repository_owner, 'jellyfin') + if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }} runs-on: ubuntu-latest needs: - openapi-head @@ -219,7 +220,7 @@ jobs: run: |- echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Download openapi-head - uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: openapi-head path: openapi-head diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 00f384155..91c2be87b 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -19,9 +19,9 @@ jobs: runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 + - uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1 with: dotnet-version: ${{ env.SDK_VERSION }} @@ -34,7 +34,7 @@ jobs: --verbosity minimal - name: Merge code coverage results - uses: danielpalme/ReportGenerator-GitHub-Action@6b06171d1a131e7fd85121120a1c00c1ed03e033 # 5.3.0 + uses: danielpalme/ReportGenerator-GitHub-Action@5808021ec4deecb0ab3da051d49b4ce65fcc20af # 5.3.8 with: reports: "**/coverage.cobertura.xml" targetdir: "merged/" diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 41ee5425e..b79185855 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -24,7 +24,7 @@ jobs: reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -51,7 +51,7 @@ jobs: reactions: eyes - name: Checkout the latest code - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -128,11 +128,11 @@ jobs: runs-on: ubuntu-latest steps: - name: pull in script - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 with: python-version: '3.12' cache: 'pip' diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml index 347c0dc65..6172455c2 100644 --- a/.github/workflows/issue-template-check.yml +++ b/.github/workflows/issue-template-check.yml @@ -10,11 +10,11 @@ jobs: issues: write steps: - name: pull in script - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: repository: jellyfin/jellyfin-triage-script - name: install python - uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 + uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1 with: python-version: '3.12' cache: 'pip' diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml index 5823c309c..5d342b7f8 100644 --- a/.github/workflows/pull-request-conflict.yml +++ b/.github/workflows/pull-request-conflict.yml @@ -15,7 +15,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Apply label - uses: eps1lon/actions-label-merge-conflict@6d74047dcef155976a15e4a124dde2c7fe0c5522 # v3.0.1 + uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2 if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: dirtyLabel: 'merge conflict' diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml index e2df02e81..575f2d756 100644 --- a/.github/workflows/release-bump-version.yaml +++ b/.github/workflows/release-bump-version.yaml @@ -33,7 +33,7 @@ jobs: yq-version: v4.9.8 - name: Checkout Repository - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ env.TAG_BRANCH }} @@ -66,7 +66,7 @@ jobs: NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} steps: - name: Checkout Repository - uses: actions/checkout@44c2b7a8a4ea60a981eaca3cf939b5f4305c123b # v4.1.5 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: ref: ${{ env.TAG_BRANCH }} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 8550222a1..edbc846d6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -183,6 +183,9 @@ - [btopherjohnson](https://github.com/btopherjohnson) - [GeorgeH005](https://github.com/GeorgeH005) - [Vedant](https://github.com/viktory36/) + - [NotSaifA](https://github.com/NotSaifA) + - [HonestlyWhoKnows](https://github.com/honestlywhoknows) + - [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode) # Emby Contributors @@ -255,3 +258,4 @@ - [JPUC1143](https://github.com/Jpuc1143/) - [0x25CBFC4F](https://github.com/0x25CBFC4F) - [Robert Lützner](https://github.com/rluetzner) + - [Nathan McCrina](https://github.com/nfmccrina) diff --git a/Directory.Packages.props b/Directory.Packages.props index ac4befca6..825301bfc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,39 +16,39 @@ <PackageVersion Include="Diacritics" Version="3.3.29" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> - <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.4.3" /> + <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.5.0" /> <PackageVersion Include="FsCheck.Xunit" Version="2.16.6" /> <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.2" /> <PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" /> - <PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" /> + <PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" /> <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" /> <PackageVersion Include="libse" Version="4.0.5" /> <PackageVersion Include="LrcParser" Version="2023.524.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.5" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.5" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.7" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.5" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.5" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.5" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.5" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.5" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.7" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.7" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7" /> <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" /> <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.5" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.5" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.7" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.7" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" /> <PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" /> - <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.10.0" /> <PackageVersion Include="MimeTypes" Version="2.4.0" /> <PackageVersion Include="Mono.Nat" Version="3.0.4" /> <PackageVersion Include="Moq" Version="4.18.4" /> @@ -59,11 +59,11 @@ <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" /> <PackageVersion Include="prometheus-net" Version="8.2.1" /> <PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" /> - <PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" /> - <PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.0" /> - <PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" /> - <PackageVersion Include="Serilog.Sinks.Console" Version="5.0.1" /> - <PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" /> + <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" /> + <PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.2" /> + <PackageVersion Include="Serilog.Sinks.Async" Version="2.0.0" /> + <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" /> + <PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> <PackageVersion Include="SharpFuzz" Version="2.1.1" /> @@ -78,14 +78,14 @@ <PackageVersion Include="System.Globalization" Version="4.3.0" /> <PackageVersion Include="System.Linq.Async" Version="6.0.1" /> <PackageVersion Include="System.Text.Encoding.CodePages" Version="8.0.0" /> - <PackageVersion Include="System.Text.Json" Version="8.0.3" /> - <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="8.0.0" /> + <PackageVersion Include="System.Text.Json" Version="8.0.4" /> + <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="8.0.1" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> <PackageVersion Include="TMDbLib" Version="2.2.0" /> <PackageVersion Include="UTF.Unknown" Version="2.5.1" /> <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> - <PackageVersion Include="xunit.runner.visualstudio" Version="2.5.8" /> + <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" /> - <PackageVersion Include="xunit" Version="2.7.1" /> + <PackageVersion Include="xunit" Version="2.9.0" /> </ItemGroup> </Project>
\ No newline at end of file diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 9d54533c2..7a01b02f3 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -107,7 +107,7 @@ namespace Emby.Naming.ExternalFiles pathInfo.Language = culture.ThreeLetterISOLanguageName; extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); } - else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase))) + else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Equals(s, StringComparison.OrdinalIgnoreCase))) { pathInfo.IsHearingImpaired = true; extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase); diff --git a/Emby.Naming/TV/TvParserHelpers.cs b/Emby.Naming/TV/TvParserHelpers.cs new file mode 100644 index 000000000..029917858 --- /dev/null +++ b/Emby.Naming/TV/TvParserHelpers.cs @@ -0,0 +1,44 @@ +using System; +using System.Linq; +using MediaBrowser.Model.Entities; + +namespace Emby.Naming.TV; + +/// <summary> +/// Helper class for TV metadata parsing. +/// </summary> +public static class TvParserHelpers +{ + private static readonly string[] _continuingState = ["Pilot", "Returning Series", "Returning"]; + private static readonly string[] _endedState = ["Cancelled", "Canceled"]; + + /// <summary> + /// Tries to parse a string into <see cref="SeriesStatus"/>. + /// </summary> + /// <param name="status">The status string.</param> + /// <param name="enumValue">The <see cref="SeriesStatus"/>.</param> + /// <returns>Returns true if parsing was successful.</returns> + public static bool TryParseSeriesStatus(string status, out SeriesStatus? enumValue) + { + if (Enum.TryParse(status, true, out SeriesStatus seriesStatus)) + { + enumValue = seriesStatus; + return true; + } + + if (_continuingState.Contains(status, StringComparer.OrdinalIgnoreCase)) + { + enumValue = SeriesStatus.Continuing; + return true; + } + + if (_endedState.Contains(status, StringComparer.OrdinalIgnoreCase)) + { + enumValue = SeriesStatus.Ended; + return true; + } + + enumValue = null; + return false; + } +} diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 39524be1d..dc845b2d7 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -104,6 +104,6 @@ namespace Emby.Server.Implementations.AppBase /// Gets the folder path to the temp directory within the cache folder. /// </summary> /// <value>The temp directory.</value> - public string TempDirectory => Path.Combine(CachePath, "temp"); + public string TempDirectory => Path.Join(Path.GetTempPath(), "jellyfin"); } } diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index c394b25bd..5bf9c4fc2 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -664,7 +664,8 @@ namespace Emby.Server.Implementations GetExports<IMetadataService>(), GetExports<IMetadataProvider>(), GetExports<IMetadataSaver>(), - GetExports<IExternalId>()); + GetExports<IExternalId>(), + GetExports<IExternalUrlProvider>()); Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>()); } diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs index f0c267627..c06cd8510 100644 --- a/Emby.Server.Implementations/ConfigurationOptions.cs +++ b/Emby.Server.Implementations/ConfigurationOptions.cs @@ -19,7 +19,8 @@ namespace Emby.Server.Implementations { FfmpegAnalyzeDurationKey, "200M" }, { PlaylistsAllowDuplicatesKey, bool.FalseString }, { BindToUnixSocketKey, bool.FalseString }, - { SqliteCacheSizeKey, "20000" } + { SqliteCacheSizeKey, "20000" }, + { SqliteDisableSecondLevelCacheKey, bool.FalseString } }; } } diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index b1c99227c..5291999dc 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading; using Jellyfin.Extensions; using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; @@ -13,6 +14,8 @@ namespace Emby.Server.Implementations.Data public abstract class BaseSqliteRepository : IDisposable { private bool _disposed = false; + private SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1); + private SqliteConnection _writeConnection; /// <summary> /// Initializes a new instance of the <see cref="BaseSqliteRepository"/> class. @@ -29,17 +32,6 @@ namespace Emby.Server.Implementations.Data protected string DbFilePath { get; set; } /// <summary> - /// Gets or sets the number of write connections to create. - /// </summary> - /// <value>Path to the DB file.</value> - protected int WriteConnectionsCount { get; set; } = 1; - - /// <summary> - /// Gets or sets the number of read connections to create. - /// </summary> - protected int ReadConnectionsCount { get; set; } = 1; - - /// <summary> /// Gets the logger. /// </summary> /// <value>The logger.</value> @@ -98,9 +90,55 @@ namespace Emby.Server.Implementations.Data } } - protected SqliteConnection GetConnection() + protected ManagedConnection GetConnection(bool readOnly = false) { - var connection = new SqliteConnection($"Filename={DbFilePath}"); + if (!readOnly) + { + _writeLock.Wait(); + if (_writeConnection is not null) + { + return new ManagedConnection(_writeConnection, _writeLock); + } + + var writeConnection = new SqliteConnection($"Filename={DbFilePath};Pooling=False"); + writeConnection.Open(); + + if (CacheSize.HasValue) + { + writeConnection.Execute("PRAGMA cache_size=" + CacheSize.Value); + } + + if (!string.IsNullOrWhiteSpace(LockingMode)) + { + writeConnection.Execute("PRAGMA locking_mode=" + LockingMode); + } + + if (!string.IsNullOrWhiteSpace(JournalMode)) + { + writeConnection.Execute("PRAGMA journal_mode=" + JournalMode); + } + + if (JournalSizeLimit.HasValue) + { + writeConnection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value); + } + + if (Synchronous.HasValue) + { + writeConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); + } + + if (PageSize.HasValue) + { + writeConnection.Execute("PRAGMA page_size=" + PageSize.Value); + } + + writeConnection.Execute("PRAGMA temp_store=" + (int)TempStore); + + return new ManagedConnection(_writeConnection = writeConnection, _writeLock); + } + + var connection = new SqliteConnection($"Filename={DbFilePath};Mode=ReadOnly"); connection.Open(); if (CacheSize.HasValue) @@ -135,17 +173,17 @@ namespace Emby.Server.Implementations.Data connection.Execute("PRAGMA temp_store=" + (int)TempStore); - return connection; + return new ManagedConnection(connection, null); } - public SqliteCommand PrepareStatement(SqliteConnection connection, string sql) + public SqliteCommand PrepareStatement(ManagedConnection connection, string sql) { var command = connection.CreateCommand(); command.CommandText = sql; return command; } - protected bool TableExists(SqliteConnection connection, string name) + protected bool TableExists(ManagedConnection connection, string name) { using var statement = PrepareStatement(connection, "select DISTINCT tbl_name from sqlite_master"); foreach (var row in statement.ExecuteQuery()) @@ -159,7 +197,7 @@ namespace Emby.Server.Implementations.Data return false; } - protected List<string> GetColumnNames(SqliteConnection connection, string table) + protected List<string> GetColumnNames(ManagedConnection connection, string table) { var columnNames = new List<string>(); @@ -174,7 +212,7 @@ namespace Emby.Server.Implementations.Data return columnNames; } - protected void AddColumn(SqliteConnection connection, string table, string columnName, string type, List<string> existingColumnNames) + protected void AddColumn(ManagedConnection connection, string table, string columnName, string type, List<string> existingColumnNames) { if (existingColumnNames.Contains(columnName, StringComparison.OrdinalIgnoreCase)) { @@ -207,6 +245,24 @@ namespace Emby.Server.Implementations.Data return; } + if (dispose) + { + _writeLock.Wait(); + try + { + _writeConnection.Dispose(); + } + finally + { + _writeLock.Release(); + } + + _writeLock.Dispose(); + } + + _writeConnection = null; + _writeLock = null; + _disposed = true; } } diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs new file mode 100644 index 000000000..860950b30 --- /dev/null +++ b/Emby.Server.Implementations/Data/ManagedConnection.cs @@ -0,0 +1,62 @@ +#pragma warning disable CS1591 + +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.Data.Sqlite; + +namespace Emby.Server.Implementations.Data; + +public sealed class ManagedConnection : IDisposable +{ + private readonly SemaphoreSlim? _writeLock; + + private SqliteConnection _db; + + private bool _disposed = false; + + public ManagedConnection(SqliteConnection db, SemaphoreSlim? writeLock) + { + _db = db; + _writeLock = writeLock; + } + + public SqliteTransaction BeginTransaction() + => _db.BeginTransaction(); + + public SqliteCommand CreateCommand() + => _db.CreateCommand(); + + public void Execute(string commandText) + => _db.Execute(commandText); + + public SqliteCommand PrepareStatement(string sql) + => _db.PrepareStatement(sql); + + public IEnumerable<SqliteDataReader> Query(string commandText) + => _db.Query(commandText); + + public void Dispose() + { + if (_disposed) + { + return; + } + + if (_writeLock is null) + { + // Read connections are managed with an internal pool + _db.Dispose(); + } + else + { + // Write lock is managed by BaseSqliteRepository + // Don't dispose here + _writeLock.Release(); + } + + _db = null!; + + _disposed = true; + } +} diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 9ef1bd66d..c2e3312ab 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -327,7 +327,6 @@ namespace Emby.Server.Implementations.Data DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db"); CacheSize = configuration.GetSqliteCacheSize(); - ReadConnectionsCount = Environment.ProcessorCount * 2; } /// <inheritdoc /> @@ -601,7 +600,7 @@ namespace Emby.Server.Implementations.Data transaction.Commit(); } - private void SaveItemsInTransaction(SqliteConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples) + private void SaveItemsInTransaction(ManagedConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples) { using (var saveItemStatement = PrepareStatement(db, SaveItemCommandText)) using (var deleteAncestorsStatement = PrepareStatement(db, "delete from AncestorIds where ItemId=@ItemId")) @@ -1047,9 +1046,10 @@ namespace Emby.Server.Implementations.Data foreach (var part in value.SpanSplit('|')) { var providerDelimiterIndex = part.IndexOf('='); - if (providerDelimiterIndex != -1 && providerDelimiterIndex == part.LastIndexOf('=')) + // Don't let empty values through + if (providerDelimiterIndex != -1 && part.Length != providerDelimiterIndex + 1) { - item.SetProviderId(part.Slice(0, providerDelimiterIndex).ToString(), part.Slice(providerDelimiterIndex + 1).ToString()); + item.SetProviderId(part[..providerDelimiterIndex].ToString(), part[(providerDelimiterIndex + 1)..].ToString()); } } } @@ -1261,7 +1261,7 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery)) { statement.TryBind("@guid", id); @@ -1298,16 +1298,15 @@ namespace Emby.Server.Implementations.Data && type != typeof(Book) && type != typeof(LiveTvProgram) && type != typeof(AudioBook) - && type != typeof(Audio) && type != typeof(MusicAlbum); } private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query) { - return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query)); + return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query), false); } - private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields) + private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields, bool skipDeserialization) { var typeString = reader.GetString(0); @@ -1320,7 +1319,7 @@ namespace Emby.Server.Implementations.Data BaseItem item = null; - if (TypeRequiresDeserialization(type)) + if (TypeRequiresDeserialization(type) && !skipDeserialization) { try { @@ -1888,7 +1887,7 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); var chapters = new List<ChapterInfo>(); - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc")) { statement.TryBind("@ItemId", item.Id); @@ -1907,7 +1906,7 @@ namespace Emby.Server.Implementations.Data { CheckDisposed(); - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex")) { statement.TryBind("@ItemId", item.Id); @@ -1981,7 +1980,7 @@ namespace Emby.Server.Implementations.Data transaction.Commit(); } - private void InsertChapters(Guid idBlob, IReadOnlyList<ChapterInfo> chapters, SqliteConnection db) + private void InsertChapters(Guid idBlob, IReadOnlyList<ChapterInfo> chapters, ManagedConnection db) { var startIndex = 0; var limit = 100; @@ -2323,7 +2322,7 @@ namespace Emby.Server.Implementations.Data columns.Add(builder.ToString()); - query.ExcludeItemIds = [..query.ExcludeItemIds, item.Id, ..item.ExtraIds]; + query.ExcludeItemIds = [.. query.ExcludeItemIds, item.Id, .. item.ExtraIds]; query.ExcludeProviderIds = item.ProviderIds; } @@ -2470,7 +2469,7 @@ namespace Emby.Server.Implementations.Data var commandText = commandTextBuilder.ToString(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, commandText)) { if (EnableJoinUserData(query)) @@ -2538,7 +2537,7 @@ namespace Emby.Server.Implementations.Data var commandText = commandTextBuilder.ToString(); var items = new List<BaseItem>(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, commandText)) { if (EnableJoinUserData(query)) @@ -2562,7 +2561,7 @@ namespace Emby.Server.Implementations.Data foreach (var row in statement.ExecuteQuery()) { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); + var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, query.SkipDeserialization); if (item is not null) { items.Add(item); @@ -2746,7 +2745,7 @@ namespace Emby.Server.Implementations.Data var list = new List<BaseItem>(); var result = new QueryResult<BaseItem>(); - using var connection = GetConnection(); + using var connection = GetConnection(true); using var transaction = connection.BeginTransaction(); if (!isReturningZeroItems) { @@ -2774,7 +2773,7 @@ namespace Emby.Server.Implementations.Data foreach (var row in statement.ExecuteQuery()) { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); + var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false); if (item is not null) { list.Add(item); @@ -2831,7 +2830,7 @@ namespace Emby.Server.Implementations.Data prepend.Add((ItemSortBy.Random, SortOrder.Ascending)); } - orderBy = query.OrderBy = [..prepend, ..orderBy]; + orderBy = query.OrderBy = [.. prepend, .. orderBy]; } else if (orderBy.Count == 0) { @@ -2928,7 +2927,7 @@ namespace Emby.Server.Implementations.Data var commandText = commandTextBuilder.ToString(); var list = new List<Guid>(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, commandText)) { if (EnableJoinUserData(query)) @@ -4477,7 +4476,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type transaction.Commit(); } - private void ExecuteWithSingleParam(SqliteConnection db, string query, Guid value) + private void ExecuteWithSingleParam(ManagedConnection db, string query, Guid value) { using (var statement = PrepareStatement(db, query)) { @@ -4510,7 +4509,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } var list = new List<string>(); - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, commandText.ToString())) { // Run this again to bind the params @@ -4548,7 +4547,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } var list = new List<PersonInfo>(); - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, commandText.ToString())) { // Run this again to bind the params @@ -4633,7 +4632,7 @@ AND Type = @InternalPersonType)"); return whereClauses; } - private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, SqliteConnection db, SqliteCommand deleteAncestorsStatement) + private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, ManagedConnection db, SqliteCommand deleteAncestorsStatement) { if (itemId.IsEmpty()) { @@ -4788,7 +4787,7 @@ AND Type = @InternalPersonType)"); var list = new List<string>(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, commandText)) { foreach (var row in statement.ExecuteQuery()) @@ -4988,8 +4987,8 @@ AND Type = @InternalPersonType)"); var list = new List<(BaseItem, ItemCounts)>(); var result = new QueryResult<(BaseItem, ItemCounts)>(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection()) - using (var transaction = connection.BeginTransaction(deferred: true)) + using (var connection = GetConnection(true)) + using (var transaction = connection.BeginTransaction()) { if (!isReturningZeroItems) { @@ -5021,7 +5020,7 @@ AND Type = @InternalPersonType)"); foreach (var row in statement.ExecuteQuery()) { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); + var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false); if (item is not null) { var countStartColumn = columns.Count - 1; @@ -5144,12 +5143,12 @@ AND Type = @InternalPersonType)"); list.AddRange(inheritedTags.Select(i => (6, i))); // Remove all invalid values. - list.RemoveAll(i => string.IsNullOrEmpty(i.Item2)); + list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2)); return list; } - private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, SqliteConnection db) + private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, ManagedConnection db) { if (itemId.IsEmpty()) { @@ -5168,7 +5167,7 @@ AND Type = @InternalPersonType)"); InsertItemValues(itemId, values, db); } - private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, SqliteConnection db) + private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, ManagedConnection db) { const int Limit = 100; var startIndex = 0; @@ -5202,12 +5201,6 @@ AND Type = @InternalPersonType)"); var itemValue = currentValueInfo.Value; - // Don't save if invalid - if (string.IsNullOrWhiteSpace(itemValue)) - { - continue; - } - statement.TryBind("@Type" + index, currentValueInfo.MagicNumber); statement.TryBind("@Value" + index, itemValue); statement.TryBind("@CleanValue" + index, GetCleanValue(itemValue)); @@ -5228,24 +5221,25 @@ AND Type = @InternalPersonType)"); throw new ArgumentNullException(nameof(itemId)); } - ArgumentNullException.ThrowIfNull(people); - CheckDisposed(); using var connection = GetConnection(); using var transaction = connection.BeginTransaction(); - // First delete chapters + // Delete all existing people first using var command = connection.CreateCommand(); command.CommandText = "delete from People where ItemId=@ItemId"; command.TryBind("@ItemId", itemId); command.ExecuteNonQuery(); - InsertPeople(itemId, people, connection); + if (people is not null) + { + InsertPeople(itemId, people, connection); + } transaction.Commit(); } - private void InsertPeople(Guid id, List<PersonInfo> people, SqliteConnection db) + private void InsertPeople(Guid id, List<PersonInfo> people, ManagedConnection db) { const int Limit = 100; var startIndex = 0; @@ -5341,7 +5335,7 @@ AND Type = @InternalPersonType)"); cmdText += " order by StreamIndex ASC"; - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) { var list = new List<MediaStream>(); @@ -5394,7 +5388,7 @@ AND Type = @InternalPersonType)"); transaction.Commit(); } - private void InsertMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, SqliteConnection db) + private void InsertMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, ManagedConnection db) { const int Limit = 10; var startIndex = 0; @@ -5728,7 +5722,7 @@ AND Type = @InternalPersonType)"); cmdText += " order by AttachmentIndex ASC"; var list = new List<MediaAttachment>(); - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) using (var statement = PrepareStatement(connection, cmdText)) { statement.TryBind("@ItemId", query.ItemId); @@ -5778,7 +5772,7 @@ AND Type = @InternalPersonType)"); private void InsertMediaAttachments( Guid id, IReadOnlyList<MediaAttachment> attachments, - SqliteConnection db, + ManagedConnection db, CancellationToken cancellationToken) { const int InsertAtOnce = 10; diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs index 20359e4ad..634eaf85e 100644 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs @@ -86,7 +86,7 @@ namespace Emby.Server.Implementations.Data } } - private void ImportUserIds(SqliteConnection db, IEnumerable<User> users) + private void ImportUserIds(ManagedConnection db, IEnumerable<User> users) { var userIdsWithUserData = GetAllUserIdsWithUserData(db); @@ -107,7 +107,7 @@ namespace Emby.Server.Implementations.Data } } - private List<Guid> GetAllUserIdsWithUserData(SqliteConnection db) + private List<Guid> GetAllUserIdsWithUserData(ManagedConnection db) { var list = new List<Guid>(); @@ -176,7 +176,7 @@ namespace Emby.Server.Implementations.Data } } - private static void SaveUserData(SqliteConnection db, long internalUserId, string key, UserItemData userData) + private static void SaveUserData(ManagedConnection db, long internalUserId, string key, UserItemData userData) { using (var statement = db.PrepareStatement("replace into UserDatas (key, userId, rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex) values (@key, @userId, @rating,@played,@playCount,@isFavorite,@playbackPositionTicks,@lastPlayedDate,@AudioStreamIndex,@SubtitleStreamIndex)")) { @@ -267,7 +267,7 @@ namespace Emby.Server.Implementations.Data ArgumentException.ThrowIfNullOrEmpty(key); - using (var connection = GetConnection()) + using (var connection = GetConnection(true)) { using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId")) { diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index d5afac266..28bb29df8 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -389,7 +389,7 @@ namespace Emby.Server.Implementations.IO var info = new FileInfo(path); if (info.Exists && - ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) != isHidden) + (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden != isHidden) { if (isHidden) { @@ -417,8 +417,8 @@ namespace Emby.Server.Implementations.IO return; } - if (((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) == readOnly - && ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) == isHidden) + if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly == readOnly + && (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden == isHidden) { return; } @@ -466,7 +466,7 @@ namespace Emby.Server.Implementations.IO File.Copy(file1, temp1, true); File.Copy(file2, file1, true); - File.Copy(temp1, file2, true); + File.Move(temp1, file2, true); } /// <inheritdoc /> diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 0a3d740cc..82db7c46b 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -122,6 +122,7 @@ namespace Emby.Server.Implementations.Images } await ProviderManager.SaveImage(item, outputPath, mimeType, imageType, null, false, cancellationToken).ConfigureAwait(false); + File.Delete(outputPath); return ItemUpdateType.ImageUpdate; } diff --git a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs index 04d90af3c..f9c10ba09 100644 --- a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs @@ -11,7 +11,6 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.Querying; namespace Emby.Server.Implementations.Images { @@ -33,12 +32,12 @@ namespace Emby.Server.Implementations.Images Parent = item, Recursive = true, DtoOptions = new DtoOptions(true), - ImageTypes = new ImageType[] { ImageType.Primary }, - OrderBy = new (ItemSortBy, SortOrder)[] - { + ImageTypes = [ImageType.Primary], + OrderBy = + [ (ItemSortBy.IsFolder, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) - }, + ], Limit = 1 }); } diff --git a/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs index ce8367363..98e26a322 100644 --- a/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs +++ b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs @@ -1,7 +1,10 @@ #pragma warning disable CS1591 +using System.Collections.Generic; +using System.Linq; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; @@ -15,5 +18,13 @@ namespace Emby.Server.Implementations.Images : base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager) { } + + protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item) + { + var items = base.GetItemsWithImages(item); + + // Ignore any folders because they can have generated collages + return items.Where(i => i is not Folder).ToList(); + } } } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index cca835e4f..cbded1ec6 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1,4 +1,5 @@ #pragma warning disable CS1591 +#pragma warning disable CA5394 using System; using System.Collections.Concurrent; @@ -16,6 +17,7 @@ using Emby.Server.Implementations.Library.Resolvers; using Emby.Server.Implementations.Library.Validators; using Emby.Server.Implementations.Playlists; using Emby.Server.Implementations.ScheduledTasks.Tasks; +using Emby.Server.Implementations.Sorting; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Extensions; @@ -1029,7 +1031,7 @@ namespace Emby.Server.Implementations.Library } } - private async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false) + public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false) { await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false); @@ -1710,13 +1712,19 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder) { - var isFirst = true; - IOrderedEnumerable<BaseItem>? orderedItems = null; foreach (var orderBy in sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null)) { - if (isFirst) + if (orderBy is RandomComparer) + { + var randomItems = items.ToArray(); + Random.Shared.Shuffle(randomItems); + items = randomItems; + // Items are no longer ordered at this point, so set orderedItems back to null + orderedItems = null; + } + else if (orderedItems is null) { orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, orderBy) @@ -1728,8 +1736,6 @@ namespace Emby.Server.Implementations.Library ? orderedItems!.ThenByDescending(i => i, orderBy) : orderedItems!.ThenBy(i => i, orderBy); // orderedItems is set during the first iteration } - - isFirst = false; } return orderedItems ?? items; @@ -1738,8 +1744,6 @@ namespace Emby.Server.Implementations.Library /// <inheritdoc /> public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy) { - var isFirst = true; - IOrderedEnumerable<BaseItem>? orderedItems = null; foreach (var (name, sortOrder) in orderBy) @@ -1750,7 +1754,15 @@ namespace Emby.Server.Implementations.Library continue; } - if (isFirst) + if (comparer is RandomComparer) + { + var randomItems = items.ToArray(); + Random.Shared.Shuffle(randomItems); + items = randomItems; + // Items are no longer ordered at this point, so set orderedItems back to null + orderedItems = null; + } + else if (orderedItems is null) { orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, comparer) @@ -1762,8 +1774,6 @@ namespace Emby.Server.Implementations.Library ? orderedItems!.ThenByDescending(i => i, comparer) : orderedItems!.ThenBy(i => i, comparer); // orderedItems is set during the first iteration } - - isFirst = false; } return orderedItems ?? items; @@ -1884,7 +1894,7 @@ namespace Emby.Server.Implementations.Library try { var index = item.GetImageIndex(img); - image = await ConvertImageToLocal(item, img, index, removeOnFailure: true).ConfigureAwait(false); + image = await ConvertImageToLocal(item, img, index, true).ConfigureAwait(false); } catch (ArgumentException) { @@ -2812,8 +2822,10 @@ namespace Emby.Server.Implementations.Library } _itemRepository.UpdatePeople(item.Id, people); - - await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false); + if (people is not null) + { + await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false); + } } public async Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex, bool removeOnFailure) diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index 5ec333cb1..bb22ca82f 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -113,6 +113,11 @@ namespace Emby.Server.Implementations.Library return true; } + if (stream.IsPgsSubtitleStream) + { + return true; + } + return false; } diff --git a/Emby.Server.Implementations/Library/ResolverHelper.cs b/Emby.Server.Implementations/Library/ResolverHelper.cs index 52be76217..c9e3a4daf 100644 --- a/Emby.Server.Implementations/Library/ResolverHelper.cs +++ b/Emby.Server.Implementations/Library/ResolverHelper.cs @@ -39,7 +39,7 @@ namespace Emby.Server.Implementations.Library item.GetParents().Any(i => i.IsLocked); // Make sure DateCreated and DateModified have values - var fileInfo = directoryService.GetFile(item.Path); + var fileInfo = directoryService.GetFileSystemEntry(item.Path); if (fileInfo is null) { return false; diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs index 1bdae7f62..f7270bec1 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs @@ -3,6 +3,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using Emby.Naming.Audio; using Emby.Naming.Common; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities.Audio; @@ -85,6 +86,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } var albumResolver = new MusicAlbumResolver(_logger, _namingOptions, _directoryService); + var albumParser = new AlbumParser(_namingOptions); var directories = args.FileSystemChildren.Where(i => i.IsDirectory); @@ -100,6 +102,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio } } + // If the folder is a multi-disc folder, then it is not an artist folder + if (albumParser.IsMultiPart(fileSystemInfo.FullName)) + { + return; + } + // If we contain a music album assume we are an artist folder if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, _directoryService)) { diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 858c5b281..abf2d0115 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -54,7 +54,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV { IndexNumber = seasonParserResult.SeasonNumber, SeriesId = series.Id, - SeriesName = series.Name + SeriesName = series.Name, + Path = seasonParserResult.IsSeasonFolder ? path : null }; if (!season.IndexNumber.HasValue || !seasonParserResult.IsSeasonFolder) @@ -78,27 +79,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV } } - if (season.IndexNumber.HasValue) + if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name)) { var seasonNumber = season.IndexNumber.Value; - if (string.IsNullOrEmpty(season.Name)) - { - var seasonNames = series.SeasonNames; - if (seasonNames.TryGetValue(seasonNumber, out var seasonName)) - { - season.Name = seasonName; - } - else - { - season.Name = seasonNumber == 0 ? - args.LibraryOptions.SeasonZeroDisplayName : - string.Format( - CultureInfo.InvariantCulture, - _localization.GetLocalizedString("NameSeasonNumber"), - seasonNumber, - args.LibraryOptions.PreferredMetadataLanguage); - } - } + season.Name = seasonNumber == 0 ? + args.LibraryOptions.SeasonZeroDisplayName : + string.Format( + CultureInfo.InvariantCulture, + _localization.GetLocalizedString("NameSeasonNumber"), + seasonNumber, + args.LibraryOptions.PreferredMetadataLanguage); } return season; diff --git a/Emby.Server.Implementations/Localization/Core/ab.json b/Emby.Server.Implementations/Localization/Core/ab.json index 0967ef424..bc6062f42 100644 --- a/Emby.Server.Implementations/Localization/Core/ab.json +++ b/Emby.Server.Implementations/Localization/Core/ab.json @@ -1 +1,3 @@ -{} +{ + "Albums": "аальбомқәа" +} diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json index ecea8df6a..e89ede10b 100644 --- a/Emby.Server.Implementations/Localization/Core/af.json +++ b/Emby.Server.Implementations/Localization/Core/af.json @@ -5,12 +5,12 @@ "Favorites": "Gunstelinge", "HeaderFavoriteShows": "Gunsteling Vertonings", "ValueSpecialEpisodeName": "Spesiale - {0}", - "HeaderAlbumArtists": "Kunstenaars se Album", + "HeaderAlbumArtists": "Album kunstenaars", "Books": "Boeke", "HeaderNextUp": "Volgende", "Movies": "Flieks", "Shows": "Televisie Reekse", - "HeaderContinueWatching": "Kyk Verder", + "HeaderContinueWatching": "Hou aan kyk", "HeaderFavoriteEpisodes": "Gunsteling Episodes", "Photos": "Foto's", "Playlists": "Snitlyste", @@ -19,7 +19,7 @@ "Sync": "Sinkroniseer", "HeaderFavoriteSongs": "Gunsteling Liedjies", "Songs": "Liedjies", - "DeviceOnlineWithName": "{0} is gekoppel", + "DeviceOnlineWithName": "{0} is aanlyn", "DeviceOfflineWithName": "{0} is ontkoppel", "Collections": "Versamelings", "Inherit": "Ontvang", @@ -61,7 +61,7 @@ "NotificationOptionPluginInstalled": "Inprop module geïnstalleer", "NotificationOptionPluginError": "Inprop module het misluk", "NotificationOptionNewLibraryContent": "Nuwe inhoud bygevoeg", - "NotificationOptionInstallationFailed": "Installering het misluk", + "NotificationOptionInstallationFailed": "Installasie mislukking", "NotificationOptionCameraImageUploaded": "Kamera foto is opgelaai", "NotificationOptionAudioPlaybackStopped": "Oudio terugspeel het gestop", "NotificationOptionAudioPlayback": "Oudio terugspeel het begin", @@ -86,9 +86,9 @@ "HomeVideos": "Tuis Videos", "HeaderRecordingGroups": "Groep Opnames", "Genres": "Genres", - "FailedLoginAttemptWithUserName": "Mislukte aansluiting van {0}", + "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}", "ChapterNameValue": "Hoofstuk {0}", - "CameraImageUploadedFrom": "'n Nuwe kamera photo opgelaai van {0}", + "CameraImageUploadedFrom": "'n Nuwe kamera foto is opgelaai vanaf {0}", "AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer", "Albums": "Albums", "TasksChannelsCategory": "Internet kanale", @@ -114,8 +114,8 @@ "TaskRefreshChapterImagesDescription": "Maak kleinkiekeis (fotos) vir films wat hoofstukke het.", "TaskRefreshChapterImages": "Verkry Hoofstuk Beelde", "Undefined": "Ongedefineerd", - "Forced": "Geforseer", - "Default": "Oorspronklik", + "Forced": "Geforseerd", + "Default": "Standaard", "TaskCleanActivityLogDescription": "Verwyder aktiwiteitsaantekeninge ouer as die opgestelde ouderdom.", "TaskCleanActivityLog": "Maak Aktiwiteitsaantekeninge Skoon", "TaskOptimizeDatabaseDescription": "Komprimeer databasis en verkort vrye ruimte. As hierdie taak uitgevoer word nadat die media versameling geskandeer is of ander veranderings aangebring is wat databasisaanpassings impliseer, kan dit die prestasie verbeter.", @@ -125,5 +125,9 @@ "External": "Ekstern", "HearingImpaired": "gehoorgestremd", "TaskRefreshTrickplayImages": "Genereer Fopspeel Beelde", - "TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling." + "TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling.", + "TaskAudioNormalizationDescription": "Skandeer lêers vir oudio-normaliseringsdata.", + "TaskAudioNormalization": "Odio Normalisering", + "TaskCleanCollectionsAndPlaylists": "Maak versamelings en snitlyste skoon", + "TaskCleanCollectionsAndPlaylistsDescription": "Verwyder items uit versamelings en snitlyste wat nie meer bestaan nie." } diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 8a95fff15..4245656ff 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -11,7 +11,7 @@ "Collections": "التجميعات", "DeviceOfflineWithName": "قُطِع الاتصال ب{0}", "DeviceOnlineWithName": "{0} متصل", - "FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فشلت من {0}", + "FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}", "Favorites": "المفضلة", "Folders": "المجلدات", "Genres": "التصنيفات", diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 77643505e..9172af516 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -52,7 +52,7 @@ "UserDownloadingItemWithValues": "{0} спампоўваецца {1}", "TaskOptimizeDatabase": "Аптымізаваць базу дадзеных", "Artists": "Выканаўцы", - "UserOfflineFromDevice": "{0} адключыўся ад {1}", + "UserOfflineFromDevice": "{0} адлучыўся ад {1}", "UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}", "TaskCleanActivityLogDescription": "Выдаляе старэйшыя за зададзены ўзрост запісы ў журнале актыўнасці.", "TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.", @@ -66,7 +66,7 @@ "AppDeviceValues": "Прыкладанне: {0}, Прылада: {1}", "Books": "Кнігі", "CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}", - "DeviceOfflineWithName": "{0} адключыўся", + "DeviceOfflineWithName": "{0} адлучыўся", "DeviceOnlineWithName": "{0} падлучаны", "Forced": "Прымусова", "HeaderRecordingGroups": "Групы запісаў", @@ -127,5 +127,7 @@ "TaskRefreshTrickplayImages": "Стварыце выявы Trickplay", "TaskRefreshTrickplayImagesDescription": "Стварае прагляд відэаролікаў для Trickplay у падключаных бібліятэках.", "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і спісы прайгравання", - "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і спісаў прайгравання, якія больш не існуюць." + "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і спісаў прайгравання, якія больш не існуюць.", + "TaskAudioNormalizationDescription": "Сканіруе файлы на прадмет нармалізацыі гуку.", + "TaskAudioNormalization": "Нармалізацыя гуку" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 7780759a8..14cfeb71a 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -22,7 +22,7 @@ "HeaderFavoriteEpisodes": "Oblíbené epizody", "HeaderFavoriteShows": "Oblíbené seriály", "HeaderFavoriteSongs": "Oblíbená hudba", - "HeaderLiveTV": "Živý přenos", + "HeaderLiveTV": "TV vysílání", "HeaderNextUp": "Další díly", "HeaderRecordingGroups": "Skupiny nahrávek", "HomeVideos": "Domácí videa", diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index b5e2c9b6b..e871a4362 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -17,7 +17,7 @@ "Genres": "Genrer", "HeaderAlbumArtists": "Albumkunstnere", "HeaderContinueWatching": "Fortsæt afspilning", - "HeaderFavoriteAlbums": "Favoritalbummer", + "HeaderFavoriteAlbums": "Favoritalbum", "HeaderFavoriteArtists": "Favoritkunstnere", "HeaderFavoriteEpisodes": "Yndlingsafsnit", "HeaderFavoriteShows": "Yndlingsserier", @@ -87,21 +87,21 @@ "UserOnlineFromDevice": "{0} er online fra {1}", "UserPasswordChangedWithName": "Adgangskode er ændret for brugeren {0}", "UserPolicyUpdatedWithName": "Brugerpolitikken er blevet opdateret for {0}", - "UserStartedPlayingItemWithValues": "{0} har påbegyndt afspilning af {1}", + "UserStartedPlayingItemWithValues": "{0} afspiller {1} på {2}", "UserStoppedPlayingItemWithValues": "{0} har afsluttet afspilning af {1} på {2}", "ValueHasBeenAddedToLibrary": "{0} er blevet tilføjet til dit mediebibliotek", "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Version {0}", "TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.", "TaskDownloadMissingSubtitles": "Hent manglende undertekster", - "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er indstillet til at blive opdateret automatisk.", + "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.", "TaskUpdatePlugins": "Opdater Plugins", "TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.", "TaskCleanLogs": "Ryd Log-mappe", "TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.", "TaskRefreshLibrary": "Scan Mediebibliotek", "TaskCleanCacheDescription": "Sletter cache-filer som systemet ikke længere bruger.", - "TaskCleanCache": "Ryd Cache-mappe", + "TaskCleanCache": "Ryd cache-mappe", "TasksChannelsCategory": "Internetkanaler", "TasksApplicationCategory": "Applikation", "TasksLibraryCategory": "Bibliotek", @@ -128,5 +128,7 @@ "TaskRefreshTrickplayImages": "Generér Trickplay Billeder", "TaskRefreshTrickplayImagesDescription": "Laver trickplay forhåndsvisninger for videoer i aktiverede biblioteker.", "TaskCleanCollectionsAndPlaylists": "Ryd op i samlinger og afspilningslister", - "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner enheder fra samlinger og afspilningslister der ikke eksisterer længere." + "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra samlinger og afspilningslister der ikke eksisterer længere.", + "TaskAudioNormalizationDescription": "Skanner filer for data vedrørende audio-normalisering.", + "TaskAudioNormalization": "Audio-normalisering" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 68a0180eb..ce98979e6 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -126,7 +126,7 @@ "External": "Extern", "HearingImpaired": "Hörgeschädigt", "TaskRefreshTrickplayImages": "Trickplay-Bilder generieren", - "TaskRefreshTrickplayImagesDescription": "Erstellt eine Trickplay-Vorschau für Videos in aktivierten Bibliotheken.", + "TaskRefreshTrickplayImagesDescription": "Erstellt ein Trickplay-Vorschauen für Videos in aktivierten Bibliotheken.", "TaskCleanCollectionsAndPlaylists": "Sammlungen und Playlisten aufräumen", "TaskCleanCollectionsAndPlaylistsDescription": "Lösche nicht mehr vorhandene Einträge aus den Sammlungen und Playlisten.", "TaskAudioNormalization": "Audio Normalisierung", diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index d4151adf3..e9ace71a5 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -11,7 +11,7 @@ "Collections": "Colecciones", "DeviceOfflineWithName": "{0} se ha desconectado", "DeviceOnlineWithName": "{0} está conectado", - "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión desde {0}", + "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}", "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index f9d62d54e..13e007b4c 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -11,7 +11,7 @@ "Collections": "Colecciones", "DeviceOfflineWithName": "{0} se ha desconectado", "DeviceOnlineWithName": "{0} está conectado", - "FailedLoginAttemptWithUserName": "Error al intentar iniciar sesión desde {0}", + "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}", "Favorites": "Favoritos", "Folders": "Carpetas", "Genres": "Géneros", diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json index c6863ff36..e7deefbb0 100644 --- a/Emby.Server.Implementations/Localization/Core/es_419.json +++ b/Emby.Server.Implementations/Localization/Core/es_419.json @@ -112,7 +112,7 @@ "CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}", "AuthenticationSucceededWithUserName": "{0} autenticado con éxito", "Application": "Aplicación", - "AppDeviceValues": "App: {0}, Dispositivo: {1}", + "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.", "TaskCleanActivityLog": "Limpiar registro de actividades", "Undefined": "Sin definir", @@ -125,5 +125,9 @@ "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", "HearingImpaired": "Discapacidad auditiva", "TaskRefreshTrickplayImagesDescription": "Crea previsualizaciones para la barra de reproducción en las bibliotecas habilitadas.", - "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción" + "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción", + "TaskAudioNormalization": "Normalización de audio", + "TaskCleanCollectionsAndPlaylistsDescription": "Quita elementos que ya no existen de colecciones y listas de reproducción.", + "TaskAudioNormalizationDescription": "Analiza los archivos para normalizar el audio.", + "TaskCleanCollectionsAndPlaylists": "Limpieza de colecciones y listas de reproducción" } diff --git a/Emby.Server.Implementations/Localization/Core/es_DO.json b/Emby.Server.Implementations/Localization/Core/es_DO.json index 0f4c7438f..8cdd06b7c 100644 --- a/Emby.Server.Implementations/Localization/Core/es_DO.json +++ b/Emby.Server.Implementations/Localization/Core/es_DO.json @@ -12,14 +12,118 @@ "Application": "Aplicación", "AppDeviceValues": "App: {0}, Dispositivo: {1}", "HeaderContinueWatching": "Continuar Viendo", - "HeaderAlbumArtists": "Artistas del Álbum", + "HeaderAlbumArtists": "Artistas del álbum", "Genres": "Géneros", "Folders": "Carpetas", "Favorites": "Favoritos", - "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido de {0}", + "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido desde {0}", "HeaderFavoriteSongs": "Canciones Favoritas", "HeaderFavoriteEpisodes": "Episodios Favoritos", "HeaderFavoriteArtists": "Artistas Favoritos", "External": "Externo", - "Default": "Predeterminado" + "Default": "Predeterminado", + "Movies": "Películas", + "MessageNamedServerConfigurationUpdatedWithValue": "La sección {0} de la configuración ha sido actualizada", + "MixedContent": "Contenido mixto", + "Music": "Música", + "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida", + "NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor", + "NotificationOptionVideoPlayback": "Reproducción de video iniciada", + "Sync": "Sincronizar", + "Shows": "Series", + "UserDownloadingItemWithValues": "{0} está descargando {1}", + "UserOfflineFromDevice": "{0} se ha desconectado desde {1}", + "UserOnlineFromDevice": "{0} está en línea desde {1}", + "TasksChannelsCategory": "Canales de Internet", + "TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.", + "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes", + "TaskOptimizeDatabaseDescription": "Compacta la base de datos y libera espacio. Ejecutar esta tarea después de escanear la biblioteca o hacer otros cambios que impliquen modificaciones en la base de datos puede mejorar el rendimiento.", + "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reproducción HLS más precisas. Esta tarea puede durar mucho tiempo.", + "TaskAudioNormalization": "Normalización de audio", + "TaskAudioNormalizationDescription": "Escanear archivos para la normalización de data.", + "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción", + "TaskCleanCollectionsAndPlaylistsDescription": "Remover elementos de colecciones y listas de reproducción que no existen.", + "TvShows": "Series de TV", + "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}", + "TaskRefreshChannels": "Actualizar canales", + "Photos": "Fotos", + "HeaderFavoriteShows": "Programas favoritos", + "TaskCleanActivityLog": "Limpiar registro de actividades", + "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}", + "System": "Sistema", + "User": "Usuario", + "Forced": "Forzado", + "PluginInstalledWithName": "{0} ha sido instalado", + "HeaderFavoriteAlbums": "Álbumes favoritos", + "TaskUpdatePlugins": "Actualizar Plugins", + "Latest": "Recientes", + "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}", + "Songs": "Canciones", + "NotificationOptionPluginError": "Falla de plugin", + "ScheduledTaskStartedWithName": "{0} iniciado", + "TasksApplicationCategory": "Aplicación", + "UserDeletedWithName": "El usuario {0} ha sido eliminado", + "TaskRefreshChapterImages": "Extraer imágenes de los capítulos", + "TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para plugins que están configurados para actualizarse automáticamente.", + "TaskRefreshPeopleDescription": "Actualiza metadatos de actores y directores en tu biblioteca de medios.", + "NotificationOptionUserLockedOut": "Usuario bloqueado", + "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día.", + "TaskCleanTranscode": "Limpiar el directorio de transcodificaciones", + "NotificationOptionPluginUpdateInstalled": "Actualización de plugin instalada", + "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida", + "TasksLibraryCategory": "Biblioteca", + "NotificationOptionPluginInstalled": "Plugin instalado", + "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}", + "VersionNumber": "Versión {0}", + "HeaderNextUp": "A continuación", + "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca", + "LabelIpAddressValue": "Dirección IP: {0}", + "NameSeasonNumber": "Temporada {0}", + "NotificationOptionNewLibraryContent": "Nuevo contenido agregado", + "Plugin": "Plugin", + "NotificationOptionAudioPlayback": "Reproducción de audio iniciada", + "NotificationOptionTaskFailed": "Falló la tarea programada", + "LabelRunningTimeValue": "Tiempo en ejecución: {0}", + "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}", + "TaskRefreshLibrary": "Escanear biblioteca de medios", + "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado", + "TasksMaintenanceCategory": "Mantenimiento", + "ProviderValue": "Proveedor: {0}", + "UserCreatedWithName": "El usuario {0} ha sido creado", + "PluginUninstalledWithName": "{0} ha sido desinstalado", + "ValueSpecialEpisodeName": "Especial - {0}", + "ScheduledTaskFailedWithName": "{0} falló", + "TaskCleanLogs": "Limpiar directorio de registros", + "NameInstallFailed": "Falló la instalación de {0}", + "UserLockedOutWithName": "El usuario {0} ha sido bloqueado", + "TaskRefreshLibraryDescription": "Escanea tu biblioteca de medios para encontrar archivos nuevos y actualizar los metadatos.", + "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo en un momento.", + "Playlists": "Listas de reproducción", + "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.", + "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor", + "TaskRefreshPeople": "Actualizar personas", + "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida", + "HeaderLiveTV": "TV en vivo", + "NameSeasonUnknown": "Temporada desconocida", + "NotificationOptionInstallationFailed": "Fallo de instalación", + "NotificationOptionPluginUninstalled": "Plugin desinstalado", + "TaskCleanCache": "Limpiar directorio caché", + "TaskRefreshChapterImagesDescription": "Crea miniaturas para videos que tienen capítulos.", + "Inherit": "Heredar", + "HeaderRecordingGroups": "Grupos de grabación", + "ItemAddedWithName": "{0} fue agregado a la biblioteca", + "TaskOptimizeDatabase": "Optimizar base de datos", + "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", + "HearingImpaired": "Discapacidad auditiva", + "HomeVideos": "Videos caseros", + "ItemRemovedWithName": "{0} fue removido de la biblioteca", + "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado", + "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}", + "MusicVideos": "Videos musicales", + "NewVersionIsAvailable": "Una nueva versión de Jellyfin está disponible para descargar.", + "PluginUpdatedWithName": "{0} ha sido actualizado", + "Undefined": "Sin definir", + "TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.", + "TaskCleanCacheDescription": "Elimina archivos caché que ya no son necesarios para el sistema.", + "TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad." } diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index 649229ee5..075bcc9a4 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -127,5 +127,7 @@ "TaskRefreshTrickplayImages": "Loo eelvaate pildid", "TaskRefreshTrickplayImagesDescription": "Loob eelvaated videotele, kus lubatud.", "TaskAudioNormalization": "Heli Normaliseerimine", - "TaskAudioNormalizationDescription": "Skaneerib faile heli normaliseerimise andmete jaoks." + "TaskAudioNormalizationDescription": "Skaneerib faile heli normaliseerimise andmete jaoks.", + "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest asjad, mida enam ei eksisteeri.", + "TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index dd8541f9d..42027dfb2 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -39,7 +39,7 @@ "MixedContent": "Contenu mixte", "Movies": "Films", "Music": "Musique", - "MusicVideos": "Vidéos musicales", + "MusicVideos": "Vidéoclips", "NameInstallFailed": "échec d'installation de {0}", "NameSeasonNumber": "Saison {0}", "NameSeasonUnknown": "Saison Inconnue", diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json index 28e54bff5..b511ed6ba 100644 --- a/Emby.Server.Implementations/Localization/Core/ga.json +++ b/Emby.Server.Implementations/Localization/Core/ga.json @@ -1,3 +1,16 @@ { - "Albums": "Albaim" + "Albums": "Albaim", + "Artists": "Ealaíontóir", + "AuthenticationSucceededWithUserName": "{0} fíordheimhnithe", + "Books": "leabhair", + "CameraImageUploadedFrom": "Tá íomhá ceamara nua uaslódáilte ó {0}", + "Channels": "Cainéil", + "ChapterNameValue": "Caibidil {0}", + "Collections": "Bailiúcháin", + "Default": "Mainneachtain", + "DeviceOfflineWithName": "scoireadh {0}", + "DeviceOnlineWithName": "{0} ceangailte", + "External": "Forimeallach", + "FailedLoginAttemptWithUserName": "Iarracht ar theip ar fhíordheimhniú ó {0}", + "Favorites": "Ceanáin" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 26eab392e..c8e036424 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -126,5 +126,9 @@ "External": "חיצוני", "HearingImpaired": "לקוי שמיעה", "TaskRefreshTrickplayImages": "יצירת תמונות המחשה", - "TaskRefreshTrickplayImagesDescription": "יוצר תמונות המחשה לסרטונים שפעילים בספריות." + "TaskRefreshTrickplayImagesDescription": "יוצר תמונות המחשה לסרטונים שפעילים בספריות.", + "TaskAudioNormalization": "נרמול שמע", + "TaskCleanCollectionsAndPlaylistsDescription": "מנקה פריטים לא קיימים מאוספים ורשימות השמעה.", + "TaskAudioNormalizationDescription": "מחפש קבצי נורמליזציה של שמע.", + "TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה" } diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index a28352219..380c08e0d 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -14,7 +14,7 @@ "Forced": "बलपूर्वक", "Folders": "फ़ोल्डर", "Favorites": "पसंदीदा", - "FailedLoginAttemptWithUserName": "{0} से लॉगिन असफल हुआ", + "FailedLoginAttemptWithUserName": "{0} से संप्रवेश असफल हुआ", "DeviceOnlineWithName": "{0} कनेक्ट हो गया है", "DeviceOfflineWithName": "{0} डिस्कनेक्ट हो गया है", "Default": "प्राथमिक", @@ -125,5 +125,7 @@ "TaskDownloadMissingSubtitlesDescription": "मेटाडेटा कॉन्फ़िगरेशन के आधार पर लापता उपशीर्षक के लिए इंटरनेट खोजता है।", "TaskKeyframeExtractorDescription": "अधिक सटीक एचएलएस प्लेलिस्ट बनाने के लिए वीडियो फ़ाइलों से मुख्य-फ़्रेम निकालता है। यह कार्य लंबे समय तक चल सकता है।", "TaskRefreshTrickplayImages": "ट्रिकप्लै चित्रों को सृजन करे", - "TaskRefreshTrickplayImagesDescription": "नियत संग्रहों में चलचित्रों का ट्रीकप्लै दर्शनों को सृजन करे." + "TaskRefreshTrickplayImagesDescription": "नियत संग्रहों में चलचित्रों का ट्रीकप्लै दर्शनों को सृजन करे.", + "TaskAudioNormalization": "श्रव्य सामान्यीकरण", + "TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index 5bb2b7d4d..6a5b8c561 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -126,5 +126,6 @@ "TaskOptimizeDatabaseDescription": "Sažima bazu podataka i uklanja prazan prostor. Pokretanje ovog zadatka, može poboljšati performanse nakon provođenja indeksiranja biblioteke ili provođenja drugih promjena koje utječu na bazu podataka.", "HearingImpaired": "Oštećen sluh", "TaskRefreshTrickplayImages": "Generiraj Trickplay Slike", - "TaskRefreshTrickplayImagesDescription": "Kreira trickplay pretpreglede za videe u omogućenim knjižnicama." + "TaskRefreshTrickplayImagesDescription": "Kreira trickplay pretpreglede za videe u omogućenim knjižnicama.", + "TaskAudioNormalization": "Normalizacija zvuka" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 5d5bb3324..31d6aaedb 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -18,14 +18,14 @@ "HeaderAlbumArtists": "Album előadók", "HeaderContinueWatching": "Megtekintés folytatása", "HeaderFavoriteAlbums": "Kedvenc Albumok", - "HeaderFavoriteArtists": "Kedvenc előadók", - "HeaderFavoriteEpisodes": "Kedvenc epizódok", - "HeaderFavoriteShows": "Kedvenc sorozatok", - "HeaderFavoriteSongs": "Kedvenc dalok", + "HeaderFavoriteArtists": "Kedvenc Előadók", + "HeaderFavoriteEpisodes": "Kedvenc Epizódok", + "HeaderFavoriteShows": "Kedvenc Sorozatok", + "HeaderFavoriteSongs": "Kedvenc Dalok", "HeaderLiveTV": "Élő TV", "HeaderNextUp": "Következik", - "HeaderRecordingGroups": "Felvételi csoportok", - "HomeVideos": "Otthoni videók", + "HeaderRecordingGroups": "Felvevő Csoportok", + "HomeVideos": "Otthoni Videók", "Inherit": "Örökölt", "ItemAddedWithName": "{0} hozzáadva a könyvtárhoz", "ItemRemovedWithName": "{0} eltávolítva a könyvtárból", diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index 78a443348..b925a482b 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -81,7 +81,7 @@ "Movies": "Film", "MessageServerConfigurationUpdated": "Konfigurasi server telah diperbarui", "MessageNamedServerConfigurationUpdatedWithValue": "Bagian konfigurasi server {0} telah diperbarui", - "FailedLoginAttemptWithUserName": "Gagal melakukan login dari {0}", + "FailedLoginAttemptWithUserName": "Gagal upaya login dari {0}", "CameraImageUploadedFrom": "Sebuah gambar kamera baru telah diunggah dari {0}", "DeviceOfflineWithName": "{0} telah terputus", "DeviceOnlineWithName": "{0} telah terhubung", @@ -125,5 +125,9 @@ "External": "Luar", "HearingImpaired": "Gangguan Pendengaran", "TaskRefreshTrickplayImages": "Hasilkan Gambar Trickplay", - "TaskRefreshTrickplayImagesDescription": "Buat pratinjau trickplay untuk video di perpustakaan yang diaktifkan." + "TaskRefreshTrickplayImagesDescription": "Buat pratinjau trickplay untuk video di perpustakaan yang diaktifkan.", + "TaskAudioNormalizationDescription": "Pindai file untuk data normalisasi audio.", + "TaskAudioNormalization": "Normalisasi Audio", + "TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan daftar putar", + "TaskCleanCollectionsAndPlaylistsDescription": "Menghapus item dari koleksi dan daftar putar yang sudah tidak ada." } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index 0f1f0b3d2..6cb55760a 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -17,7 +17,7 @@ "Genres": "Stefnur", "Folders": "Möppur", "Favorites": "Uppáhalds", - "FailedLoginAttemptWithUserName": "{0} reyndi að auðkenna sig", + "FailedLoginAttemptWithUserName": "{0} mistókst að auðkenna sig", "DeviceOnlineWithName": "{0} hefur tengst", "DeviceOfflineWithName": "{0} hefur aftengst", "Collections": "Söfn", @@ -123,5 +123,11 @@ "TaskRefreshChapterImages": "Plokka kafla-myndir", "TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.", "Forced": "Þvingað", - "External": "Útvær" + "External": "Útvær", + "TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir fyrir myndbönd í virkum söfnum.", + "TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir", + "TaskAudioNormalization": "Hljóðstöðlun", + "TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.", + "TaskCleanCollectionsAndPlaylists": "Hreinsa söfn og spilunarlista", + "TaskCleanCollectionsAndPlaylistsDescription": "Fjarlægir hluti úr söfnum og spilalistum sem eru ekki lengur til." } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index da8d38e40..0e694af02 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -51,10 +51,10 @@ "NotificationOptionCameraImageUploaded": "Immagine fotocamera caricata", "NotificationOptionInstallationFailed": "Installazione fallita", "NotificationOptionNewLibraryContent": "Nuovo contenuto aggiunto", - "NotificationOptionPluginError": "Errore del Plug-in", - "NotificationOptionPluginInstalled": "Plug-in installato", - "NotificationOptionPluginUninstalled": "Plug-in disinstallato", - "NotificationOptionPluginUpdateInstalled": "Aggiornamento del plug-in installato", + "NotificationOptionPluginError": "Errore del plugin", + "NotificationOptionPluginInstalled": "Plugin installato", + "NotificationOptionPluginUninstalled": "Plugin disinstallato", + "NotificationOptionPluginUpdateInstalled": "Aggiornamento plugin installato", "NotificationOptionServerRestartRequired": "Riavvio del server necessario", "NotificationOptionTaskFailed": "Operazione pianificata fallita", "NotificationOptionUserLockedOut": "Utente bloccato", @@ -68,10 +68,10 @@ "PluginUpdatedWithName": "{0} è stato aggiornato", "ProviderValue": "Provider: {0}", "ScheduledTaskFailedWithName": "{0} fallito", - "ScheduledTaskStartedWithName": "{0} avviati", + "ScheduledTaskStartedWithName": "{0} avviato", "ServerNameNeedsToBeRestarted": "{0} deve essere riavviato", "Shows": "Serie TV", - "Songs": "Canzoni", + "Songs": "Brani", "StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.", "SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}", "SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}", @@ -83,52 +83,52 @@ "UserDeletedWithName": "L'utente {0} è stato rimosso", "UserDownloadingItemWithValues": "{0} sta scaricando {1}", "UserLockedOutWithName": "L'utente {0} è stato bloccato", - "UserOfflineFromDevice": "{0} si è disconnesso su {1}", + "UserOfflineFromDevice": "{0} si è disconnesso da {1}", "UserOnlineFromDevice": "{0} è online su {1}", "UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}", "UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}", - "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di \"{1}\" su {2}", + "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1} su {2}", "UserStoppedPlayingItemWithValues": "{0} ha interrotto la riproduzione di {1} su {2}", "ValueHasBeenAddedToLibrary": "{0} è stato aggiunto alla tua libreria multimediale", "ValueSpecialEpisodeName": "Speciale - {0}", "VersionNumber": "Versione {0}", - "TaskRefreshChannelsDescription": "Aggiorna le informazioni dei canali Internet.", + "TaskRefreshChannelsDescription": "Aggiorna le informazioni dei canali internet.", "TaskDownloadMissingSubtitlesDescription": "Cerca su internet i sottotitoli mancanti basandosi sulle configurazioni dei metadati.", "TaskDownloadMissingSubtitles": "Scarica i sottotitoli mancanti", - "TaskRefreshChannels": "Aggiorna i canali", - "TaskCleanTranscodeDescription": "Cancella i file di transcode più vecchi di un giorno.", - "TaskCleanTranscode": "Svuota la cartella del transcoding", - "TaskUpdatePluginsDescription": "Scarica e installa gli aggiornamenti per i plugin che sono stati configurati per essere aggiornati contemporaneamente.", - "TaskUpdatePlugins": "Aggiorna i Plugin", - "TaskRefreshPeopleDescription": "Aggiorna i metadati per gli attori e registi nella tua libreria multimediale.", - "TaskRefreshPeople": "Aggiornamento Persone", + "TaskRefreshChannels": "Aggiorna canali", + "TaskCleanTranscodeDescription": "Cancella i file di transcodifica più vecchi di un giorno.", + "TaskCleanTranscode": "Svuota la cartella della transcodifica", + "TaskUpdatePluginsDescription": "Scarica e installa gli aggiornamenti per i plugin configurati per l'aggiornamento automatico.", + "TaskUpdatePlugins": "Aggiorna i plugin", + "TaskRefreshPeopleDescription": "Aggiorna i metadati degli attori e registi nella tua libreria.", + "TaskRefreshPeople": "Aggiorna Persone", "TaskCleanLogsDescription": "Rimuovi i file di log più vecchi di {0} giorni.", "TaskCleanLogs": "Pulisci la cartella dei log", - "TaskRefreshLibraryDescription": "Analizza la tua libreria multimediale per nuovi file e rinnova i metadati.", - "TaskRefreshLibrary": "Scan Librerie", - "TaskRefreshChapterImagesDescription": "Crea le thumbnail per i video che hanno capitoli.", + "TaskRefreshLibraryDescription": "Scansiona la libreria alla ricerca di nuovi file e aggiorna i metadati.", + "TaskRefreshLibrary": "Scansione della libreria", + "TaskRefreshChapterImagesDescription": "Crea le miniature per i video che hanno capitoli.", "TaskRefreshChapterImages": "Estrai immagini capitolo", "TaskCleanCacheDescription": "Cancella i file di cache non più necessari al sistema.", - "TaskCleanCache": "Pulisci la directory della cache", + "TaskCleanCache": "Pulisci la cartella della cache", "TasksChannelsCategory": "Canali su Internet", "TasksApplicationCategory": "Applicazione", "TasksLibraryCategory": "Libreria", "TasksMaintenanceCategory": "Manutenzione", "TaskCleanActivityLog": "Attività di Registro Completate", - "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata.", + "TaskCleanActivityLogDescription": "Elimina le voci del registro delle attività più vecchie dell’età configurata.", "Undefined": "Non Definito", "Forced": "Forzato", "Default": "Predefinito", - "TaskOptimizeDatabaseDescription": "Compatta Database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altri cambiamenti inerenti il database potrebbe aumentarne la performance.", - "TaskOptimizeDatabase": "Ottimizza Database", + "TaskOptimizeDatabaseDescription": "Compatta database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altre modifiche inerenti il database potrebbe aumentarne le prestazioni.", + "TaskOptimizeDatabase": "Ottimizza database", "TaskKeyframeExtractor": "Estrattore di Keyframe", "TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori playlist HLS. Questa procedura potrebbe richiedere molto tempo.", "External": "Esterno", - "HearingImpaired": "con problemi di udito", + "HearingImpaired": "Non Udenti", "TaskRefreshTrickplayImages": "Genera immagini Trickplay", "TaskRefreshTrickplayImagesDescription": "Crea anteprime trickplay per i video nelle librerie abilitate.", - "TaskCleanCollectionsAndPlaylists": "Ripulire le raccolte e le playlist", - "TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle raccolte e dalle playlist che non esistono più.", - "TaskAudioNormalization": "Normalizzazione Audio", - "TaskAudioNormalizationDescription": "Scansione files per normalizzazione audio." + "TaskCleanCollectionsAndPlaylists": "Ripulire le collezioni e le playlist", + "TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle collezioni e dalle playlist che non esistono più.", + "TaskAudioNormalization": "Normalizzazione dell'audio", + "TaskAudioNormalizationDescription": "Scansiona i file alla ricerca dei dati per la normalizzazione dell'audio." } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index 6e58ef834..78c3d0a40 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -17,7 +17,7 @@ "Inherit": "Pārmantot", "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}", "VersionNumber": "Versija {0}", - "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai", + "ValueHasBeenAddedToLibrary": "{0} tika pievienots jūsu multvides bibliotēkai", "UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}", "UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}", "UserPasswordChangedWithName": "Lietotāja {0} parole tika nomainīta", @@ -76,7 +76,7 @@ "Genres": "Žanri", "Folders": "Mapes", "Favorites": "Izlase", - "FailedLoginAttemptWithUserName": "Neizdevies ieiešanas mēģinājums no {0}", + "FailedLoginAttemptWithUserName": "Neveiksmīgs ielogošanos mēģinājums no {0}", "DeviceOnlineWithName": "Savienojums ar {0} ir izveidots", "DeviceOfflineWithName": "Savienojums ar {0} ir pārtraukts", "Collections": "Kolekcijas", @@ -95,7 +95,7 @@ "TaskRefreshChapterImages": "Izvilkt nodaļu attēlus", "TasksApplicationCategory": "Lietotne", "TasksLibraryCategory": "Bibliotēka", - "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.", + "TaskDownloadMissingSubtitlesDescription": "Meklē trūkstošus subtitrus internēta balstoties uz metadatu uzstādījumiem.", "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošos subtitrus", "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.", "TaskRefreshChannels": "Atjaunot kanālus", @@ -105,8 +105,8 @@ "TaskUpdatePlugins": "Atjaunot paplašinājumus", "TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.", "TaskRefreshPeople": "Atjaunot cilvēkus", - "TaskCleanLogsDescription": "Nodzēš logdatnes, kas ir senākas par {0} dienām.", - "TaskCleanLogs": "Iztīrīt logdatņu mapi", + "TaskCleanLogsDescription": "Nodzēš žurnāla ierakstus, kas ir senāki par {0} dienām.", + "TaskCleanLogs": "Iztīrīt žurnālu mapi", "TaskRefreshLibraryDescription": "Skenē jūsu multivides bibliotēku, lai atrastu jaunas datnes, un atsvaidzina metadatus.", "TaskRefreshLibrary": "Skenēt multivides bibliotēku", "TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.", @@ -125,5 +125,9 @@ "TaskKeyframeExtractor": "Atslēgkadru ekstraktors", "TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs.", "TaskRefreshTrickplayImages": "Ģenerēt partīšanas attēlus", - "TaskRefreshTrickplayImagesDescription": "Izveido priekšskatījumus videoklipu pārtīšanai iespējotajās bibliotēkās." + "TaskRefreshTrickplayImagesDescription": "Izveido priekšskatījumus videoklipu pārtīšanai iespējotajās bibliotēkās.", + "TaskAudioNormalization": "Audio normalizācija", + "TaskCleanCollectionsAndPlaylistsDescription": "Noņem elemēntus no kolekcijām un atskaņošanas sarakstiem, kuri vairs neeksistē.", + "TaskAudioNormalizationDescription": "Skanē failus priekš audio normālizācijas informācijas.", + "TaskCleanCollectionsAndPlaylists": "Notīrīt kolekcijas un atskaņošanas sarakstus" } diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json index 28c7dd190..5c3449381 100644 --- a/Emby.Server.Implementations/Localization/Core/ml.json +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -6,7 +6,7 @@ "ChapterNameValue": "അധ്യായം {0}", "DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു", "DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു", - "FailedLoginAttemptWithUserName": "{0} - എന്നതിൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു", + "FailedLoginAttemptWithUserName": "{0}ൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു", "Forced": "നിർബന്ധിച്ചു", "HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ", "HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ", @@ -125,5 +125,9 @@ "TaskKeyframeExtractorDescription": "കൂടുതൽ കൃത്യമായ HLS പ്ലേലിസ്റ്റുകൾ സൃഷ്ടിക്കുന്നതിന് വീഡിയോ ഫയലുകളിൽ നിന്ന് കീഫ്രെയിമുകൾ എക്സ്ട്രാക്റ്റ് ചെയ്യുന്നു. ഈ പ്രവർത്തനം പൂർത്തിയാവാൻ കുറച്ചധികം സമയം എടുത്തേക്കാം.", "TaskKeyframeExtractor": "കീഫ്രെയിം എക്സ്ട്രാക്റ്റർ", "TaskCleanCollectionsAndPlaylistsDescription": "നിലവിലില്ലാത്ത ശേഖരങ്ങളിൽ നിന്നും പ്ലേലിസ്റ്റുകളിൽ നിന്നും ഇനങ്ങൾ നീക്കംചെയ്യുന്നു.", - "TaskCleanCollectionsAndPlaylists": "ശേഖരങ്ങളും പ്ലേലിസ്റ്റുകളും വൃത്തിയാക്കുക" + "TaskCleanCollectionsAndPlaylists": "ശേഖരങ്ങളും പ്ലേലിസ്റ്റുകളും വൃത്തിയാക്കുക", + "TaskAudioNormalization": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുക", + "TaskAudioNormalizationDescription": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുന്ന ഡാറ്റയ്ക്കായി ഫയലുകൾ സ്കാൻ ചെയ്യുക.", + "TaskRefreshTrickplayImages": "ട്രിക്ക് പ്ലേ ചിത്രങ്ങൾ സൃഷ്ടിക്കുക", + "TaskRefreshTrickplayImagesDescription": "പ്രവർത്തനക്ഷമമാക്കിയ ലൈബ്രറികളിൽ വീഡിയോകൾക്കായി ട്രിക്ക്പ്ലേ പ്രിവ്യൂകൾ സൃഷ്ടിക്കുന്നു." } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index b6c15d871..b66818ddc 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -126,5 +126,9 @@ "External": "Ekstern", "HearingImpaired": "Hørselshemmet", "TaskRefreshTrickplayImages": "Generer Trickplay bilder", - "TaskRefreshTrickplayImagesDescription": "Oppretter trickplay-forhåndsvisninger for videoer i aktiverte biblioteker." + "TaskRefreshTrickplayImagesDescription": "Oppretter trickplay-forhåndsvisninger for videoer i aktiverte biblioteker.", + "TaskCleanCollectionsAndPlaylists": "Rydd kolleksjoner og spillelister", + "TaskAudioNormalization": "Lyd Normalisering", + "TaskAudioNormalizationDescription": "Skan filer for lyd normaliserende data", + "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra kolleksjoner og spillelister som ikke lengere finnes" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 9fe019dc9..4f076b680 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -124,7 +124,7 @@ "TaskKeyframeExtractorDescription": "Haalt keyframes uit videobestanden om preciezere HLS-afspeellijsten te maken. Deze taak kan lang duren.", "TaskKeyframeExtractor": "Keyframes uitpakken", "External": "Extern", - "HearingImpaired": "Slechthorend", + "HearingImpaired": "Slechthorenden", "TaskRefreshTrickplayImages": "Trickplay-afbeeldingen genereren", "TaskRefreshTrickplayImagesDescription": "Creëert trickplay-voorvertoningen voor video's in bibliotheken waarvoor dit is ingeschakeld.", "TaskCleanCollectionsAndPlaylists": "Collecties en afspeellijsten opruimen", diff --git a/Emby.Server.Implementations/Localization/Core/nn.json b/Emby.Server.Implementations/Localization/Core/nn.json index d0c914de3..ff6376258 100644 --- a/Emby.Server.Implementations/Localization/Core/nn.json +++ b/Emby.Server.Implementations/Localization/Core/nn.json @@ -118,5 +118,6 @@ "Undefined": "Udefinert", "Forced": "Tvungen", "Default": "Standard", - "External": "Ekstern" + "External": "Ekstern", + "HearingImpaired": "Nedsett høyrsel" } diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json index 1f982feaf..a25099ee0 100644 --- a/Emby.Server.Implementations/Localization/Core/pa.json +++ b/Emby.Server.Implementations/Localization/Core/pa.json @@ -104,7 +104,7 @@ "Forced": "ਮਜਬੂਰ", "Folders": "ਫੋਲਡਰ", "Favorites": "ਮਨਪਸੰਦ", - "FailedLoginAttemptWithUserName": "{0} ਤੋਂ ਲਾਗਇਨ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ", + "FailedLoginAttemptWithUserName": "{0} ਤੋਂ ਲਾਗਇਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ", "DeviceOnlineWithName": "{0} ਜੁੜਿਆ ਹੋਇਆ ਹੈ", "DeviceOfflineWithName": "{0} ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ", "Default": "ਡਿਫੌਲਟ", @@ -119,5 +119,6 @@ "AppDeviceValues": "ਐਪ: {0}, ਜੰਤਰ: {1}", "Albums": "ਐਲਬਮਾਂ", "TaskOptimizeDatabase": "ਡਾਟਾਬੇਸ ਅਨੁਕੂਲ ਬਣਾਓ", - "External": "ਬਾਹਰੀ" + "External": "ਬਾਹਰੀ", + "HearingImpaired": "ਸੁਨਣ ਵਿਚ ਕਮਜ਼ੋਰ" } diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json index bc5dd923f..f36385be2 100644 --- a/Emby.Server.Implementations/Localization/Core/pl.json +++ b/Emby.Server.Implementations/Localization/Core/pl.json @@ -98,8 +98,8 @@ "TaskRefreshChannels": "Odśwież kanały", "TaskCleanTranscodeDescription": "Usuwa transkodowane pliki starsze niż 1 dzień.", "TaskCleanTranscode": "Wyczyść folder transkodowania", - "TaskUpdatePluginsDescription": "Pobiera i instaluje aktualizacje dla pluginów, które są skonfigurowane do automatycznej aktualizacji.", - "TaskUpdatePlugins": "Aktualizuj pluginy", + "TaskUpdatePluginsDescription": "Pobiera i instaluje aktualizacje wtyczek, które są skonfigurowane do automatycznej aktualizacji.", + "TaskUpdatePlugins": "Aktualizuj wtyczki", "TaskRefreshPeopleDescription": "Odświeża metadane o aktorów i reżyserów w Twojej bibliotece mediów.", "TaskRefreshPeople": "Odśwież obsadę", "TaskCleanLogsDescription": "Kasuje pliki logów starsze niż {0} dni.", diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index 537a6d3f2..cd0120fc7 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -78,7 +78,7 @@ "Genres": "Genuri", "Folders": "Dosare", "Favorites": "Favorite", - "FailedLoginAttemptWithUserName": "Încercare de conectare nereușită de la {0}", + "FailedLoginAttemptWithUserName": "Încercare de conectare eșuată pentru {0}", "DeviceOnlineWithName": "{0} este conectat", "DeviceOfflineWithName": "{0} s-a deconectat", "Collections": "Colecții", diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 3d3f88709..3eb1e0468 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -11,7 +11,7 @@ "Collections": "Коллекции", "DeviceOfflineWithName": "{0} - отключено", "DeviceOnlineWithName": "{0} - подключено", - "FailedLoginAttemptWithUserName": "{0} - попытка входа неудачна", + "FailedLoginAttemptWithUserName": "Неудачная попытка входа с {0}", "Favorites": "Избранное", "Folders": "Папки", "Genres": "Жанры", @@ -31,7 +31,7 @@ "ItemRemovedWithName": "{0} - изъято из медиатеки", "LabelIpAddressValue": "IP-адрес: {0}", "LabelRunningTimeValue": "Длительность: {0}", - "Latest": "Последние добавленные", + "Latest": "Последние", "MessageApplicationUpdated": "Jellyfin Server был обновлён", "MessageApplicationUpdatedTo": "Jellyfin Server был обновлён до {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Конфигурация сервера (раздел {0}) была обновлена", @@ -128,5 +128,7 @@ "TaskRefreshTrickplayImages": "Сгенерировать изображения для Trickplay", "TaskRefreshTrickplayImagesDescription": "Создает предпросмотры для Trickplay для видео в библиотеках, где эта функция включена.", "TaskCleanCollectionsAndPlaylists": "Очистка коллекций и списков воспроизведения", - "TaskCleanCollectionsAndPlaylistsDescription": "Удаляет элементы из коллекций и списков воспроизведения, которые больше не существуют." + "TaskCleanCollectionsAndPlaylistsDescription": "Удаляет элементы из коллекций и списков воспроизведения, которые больше не существуют.", + "TaskAudioNormalization": "Нормализация звука", + "TaskAudioNormalizationDescription": "Сканирует файлы на наличие данных о нормализации звука." } diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index e2f238d3d..f40c4478a 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -127,5 +127,8 @@ "HearingImpaired": "Hörselskadad", "TaskRefreshTrickplayImages": "Generera Trickplay-bilder", "TaskRefreshTrickplayImagesDescription": "Skapar trickplay-förhandsvisningar för videor i aktiverade bibliotek.", - "TaskCleanCollectionsAndPlaylists": "Rensa samlingar och spellistor" + "TaskCleanCollectionsAndPlaylists": "Rensa upp samlingar och spellistor", + "TaskAudioNormalization": "Ljudnormalisering", + "TaskCleanCollectionsAndPlaylistsDescription": "Tar bort objekt från samlingar och spellistor som inte längre finns.", + "TaskAudioNormalizationDescription": "Skannar filer för ljudnormaliseringsdata." } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 3cdf743d5..da32e9776 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -123,5 +123,7 @@ "External": "ภายนอก", "HearingImpaired": "บกพร่องทางการได้ยิน", "TaskKeyframeExtractor": "ตัวแยกคีย์เฟรม", - "TaskKeyframeExtractorDescription": "แยกคีย์เฟรมจากไฟล์วีดีโอเพื่อสร้างรายการ HLS ให้ถูกต้อง. กระบวนการนี้อาจใช้ระยะเวลานาน" + "TaskKeyframeExtractorDescription": "แยกคีย์เฟรมจากไฟล์วีดีโอเพื่อสร้างรายการ HLS ให้ถูกต้อง. กระบวนการนี้อาจใช้ระยะเวลานาน", + "TaskRefreshTrickplayImages": "สร้างไฟล์รูปภาพสำหรับ Trickplay", + "TaskRefreshTrickplayImagesDescription": "สร้างภาพตัวอย่างของวีดีโอในคลังที่เปิดใช้งาน Trickplay" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 8a25f0758..1dceadc61 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -11,7 +11,7 @@ "Collections": "Koleksiyonlar", "DeviceOfflineWithName": "{0} bağlantısı kesildi", "DeviceOnlineWithName": "{0} bağlı", - "FailedLoginAttemptWithUserName": "{0} kullanıcısının giriş denemesi başarısız oldu", + "FailedLoginAttemptWithUserName": "{0} kullanıcısının başarısız oturum açma girişimi", "Favorites": "Favoriler", "Folders": "Klasörler", "Genres": "Türler", diff --git a/Emby.Server.Implementations/Localization/Core/uz.json b/Emby.Server.Implementations/Localization/Core/uz.json index 43935f224..a1b3035f3 100644 --- a/Emby.Server.Implementations/Localization/Core/uz.json +++ b/Emby.Server.Implementations/Localization/Core/uz.json @@ -8,5 +8,20 @@ "Channels": "Kanallar", "Books": "Kitoblar", "Artists": "Ijrochilar", - "Albums": "Albomlar" + "Albums": "Albomlar", + "AuthenticationSucceededWithUserName": "{0} muvaffaqiyatli tasdiqlandi", + "AppDeviceValues": "Ilova: {0}, Qurilma: {1}", + "Application": "Ilova", + "CameraImageUploadedFrom": "{0}dan yangi kamera rasmi yuklandi", + "DeviceOnlineWithName": "{0} ulangan", + "ItemRemovedWithName": "{0} kutbxonadan o'chirildi", + "External": "Tashqi", + "FailedLoginAttemptWithUserName": "Muvafaqiyatsiz kirishlar soni {0}", + "Forced": "Majburiy", + "ChapterNameValue": "{0}chi bo'lim", + "DeviceOfflineWithName": "{0} aloqa uzildi", + "HeaderLiveTV": "Jonli TV", + "HeaderNextUp": "Keyingisi", + "ItemAddedWithName": "{0} kutbxonaga qo'shildi", + "LabelIpAddressValue": "IP manzil: {0}" } diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index af9b54ad1..4bedfe3b2 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -103,11 +103,11 @@ "HeaderFavoriteEpisodes": "Tập Phim Yêu Thích", "HeaderFavoriteArtists": "Nghệ Sĩ Yêu Thích", "HeaderFavoriteAlbums": "Album Ưa Thích", - "FailedLoginAttemptWithUserName": "Đăng nhập không thành công thử từ {0}", + "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}", "DeviceOnlineWithName": "{0} đã kết nối", "DeviceOfflineWithName": "{0} đã ngắt kết nối", "ChapterNameValue": "Phân Cảnh {0}", - "Channels": "Các Kênh", + "Channels": "Kênh", "CameraImageUploadedFrom": "Một hình ảnh máy ảnh mới đã được tải lên từ {0}", "Books": "Sách", "AuthenticationSucceededWithUserName": "{0} xác thực thành công", @@ -127,5 +127,7 @@ "TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay", "TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.", "TaskCleanCollectionsAndPlaylists": "Dọn dẹp bộ sưu tập và danh sách phát", - "TaskCleanCollectionsAndPlaylistsDescription": "Xóa các mục khỏi bộ sưu tập và danh sách phát không còn tồn tại." + "TaskCleanCollectionsAndPlaylistsDescription": "Xóa các mục khỏi bộ sưu tập và danh sách phát không còn tồn tại.", + "TaskAudioNormalization": "Chuẩn Hóa Âm Thanh", + "TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh." } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index eb44edbec..808f73793 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -11,7 +11,7 @@ "Collections": "合集", "DeviceOfflineWithName": "{0} 已断开", "DeviceOnlineWithName": "{0} 已连接", - "FailedLoginAttemptWithUserName": "从 {0} 尝试登录失败", + "FailedLoginAttemptWithUserName": "来自 {0} 的登录尝试失败", "Favorites": "我的最爱", "Folders": "文件夹", "Genres": "类型", diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 4f77eea3b..f06bbc591 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -127,5 +127,7 @@ "TaskRefreshTrickplayImages": "生成快轉縮圖", "TaskRefreshTrickplayImagesDescription": "為啟用快轉縮圖的媒體庫生成快轉縮圖。", "TaskCleanCollectionsAndPlaylists": "清理系列和播放清單", - "TaskCleanCollectionsAndPlaylistsDescription": "清理系列和播放清單中已不存在的項目。" + "TaskCleanCollectionsAndPlaylistsDescription": "清理系列和播放清單中已不存在的項目。", + "TaskAudioNormalization": "音量標準化", + "TaskAudioNormalizationDescription": "掃描文件以找出音量標準化資料。" } diff --git a/Emby.Server.Implementations/Localization/Ratings/au.csv b/Emby.Server.Implementations/Localization/Ratings/au.csv index 688125917..6e12759a4 100644 --- a/Emby.Server.Implementations/Localization/Ratings/au.csv +++ b/Emby.Server.Implementations/Localization/Ratings/au.csv @@ -1,11 +1,11 @@ Exempt,0 G,0 7+,7 +PG,15 M,15 MA,15 MA15+,15 MA 15+,15 -PG,16 16+,16 R,18 R18+,18 diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 8a35b96b3..47ff22c0b 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -170,8 +170,13 @@ namespace Emby.Server.Implementations.Playlists private List<Playlist> GetUserPlaylists(Guid userId) { var user = _userManager.GetUserById(userId); + var playlistsFolder = GetPlaylistsFolder(userId); + if (playlistsFolder is null) + { + return []; + } - return GetPlaylistsFolder(userId).GetChildren(user, true).OfType<Playlist>().ToList(); + return playlistsFolder.GetChildren(user, true).OfType<Playlist>().ToList(); } private static string GetTargetPath(string path) @@ -184,11 +189,11 @@ namespace Emby.Server.Implementations.Playlists return path; } - private IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, MediaType playlistMediaType, User user, DtoOptions options) + private IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, User user, DtoOptions options) { var items = itemIds.Select(_libraryManager.GetItemById).Where(i => i is not null); - return Playlist.GetPlaylistItems(playlistMediaType, items, user, options); + return Playlist.GetPlaylistItems(items, user, options); } public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId) @@ -208,7 +213,7 @@ namespace Emby.Server.Implementations.Playlists ?? throw new ArgumentException("No Playlist exists with Id " + playlistId); // Retrieve all the items to be added to the playlist - var newItems = GetPlaylistItems(newItemIds, playlist.MediaType, user, options) + var newItems = GetPlaylistItems(newItemIds, user, options) .Where(i => i.SupportsAddingToPlaylist); // Filter out duplicate items, if necessary diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index 04d6ed0f2..301c04915 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -8,6 +8,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; @@ -28,7 +29,7 @@ public partial class AudioNormalizationTask : IScheduledTask private readonly IItemRepository _itemRepository; private readonly ILibraryManager _libraryManager; private readonly IMediaEncoder _mediaEncoder; - private readonly IConfigurationManager _configurationManager; + private readonly IApplicationPaths _applicationPaths; private readonly ILocalizationManager _localization; private readonly ILogger<AudioNormalizationTask> _logger; @@ -38,21 +39,21 @@ public partial class AudioNormalizationTask : IScheduledTask /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param> /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> - /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger{AudioNormalizationTask}"/> interface.</param> public AudioNormalizationTask( IItemRepository itemRepository, ILibraryManager libraryManager, IMediaEncoder mediaEncoder, - IConfigurationManager configurationManager, + IApplicationPaths applicationPaths, ILocalizationManager localizationManager, ILogger<AudioNormalizationTask> logger) { _itemRepository = itemRepository; _libraryManager = libraryManager; _mediaEncoder = mediaEncoder; - _configurationManager = configurationManager; + _applicationPaths = applicationPaths; _localization = localizationManager; _logger = logger; } @@ -69,7 +70,7 @@ public partial class AudioNormalizationTask : IScheduledTask /// <inheritdoc /> public string Key => "AudioNormalization"; - [GeneratedRegex(@"I:\s+(.*?)\s+LUFS")] + [GeneratedRegex(@"^\s+I:\s+(.*?)\s+LUFS")] private static partial Regex LUFSRegex(); /// <inheritdoc /> @@ -105,13 +106,22 @@ public partial class AudioNormalizationTask : IScheduledTask continue; } - var tempFile = Path.Join(_configurationManager.GetTranscodePath(), Guid.NewGuid() + ".concat"); + _logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id); + var tempDir = _applicationPaths.TempDirectory; + Directory.CreateDirectory(tempDir); + var tempFile = Path.Join(tempDir, a.Id + ".concat"); var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal))); await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false); - a.LUFS = await CalculateLUFSAsync( - string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile), - cancellationToken).ConfigureAwait(false); - File.Delete(tempFile); + try + { + a.LUFS = await CalculateLUFSAsync( + string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile), + cancellationToken).ConfigureAwait(false); + } + finally + { + File.Delete(tempFile); + } } _itemRepository.SaveItems(albums, cancellationToken); @@ -179,16 +189,17 @@ public partial class AudioNormalizationTask : IScheduledTask } using var reader = process.StandardError; - var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); - MatchCollection split = LUFSRegex().Matches(output); - - if (split.Count != 0) + await foreach (var line in reader.ReadAllLinesAsync(cancellationToken)) { - return float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); + Match match = LUFSRegex().Match(line); + + if (match.Success) + { + return float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat); + } } - _logger.LogError("Failed to find LUFS value in output:\n{Output}", output); + _logger.LogError("Failed to find LUFS value in output"); return null; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs index 19b245464..804097219 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs @@ -127,15 +127,8 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask { _logger.LogDebug("Updating {FolderName}", folder.Name); folder.LinkedChildren = folder.LinkedChildren.Except(itemsToRemove).ToArray(); + _providerManager.SaveMetadataAsync(folder, ItemUpdateType.MetadataEdit); folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken); - - _providerManager.QueueRefresh( - folder.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - ForceSave = true - }, - RefreshPriority.High); } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs index 03935b384..fc3ad90f6 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; @@ -133,53 +134,14 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks cancellationToken.ThrowIfCancellationRequested(); - DeleteFile(file.FullName); + FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger); index++; } - DeleteEmptyFolders(directory); + FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger); progress.Report(100); } - - private void DeleteEmptyFolders(string parent) - { - foreach (var directory in _fileSystem.GetDirectoryPaths(parent)) - { - DeleteEmptyFolders(directory); - if (!_fileSystem.GetFileSystemEntryPaths(directory).Any()) - { - try - { - Directory.Delete(directory, false); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogError(ex, "Error deleting directory {Path}", directory); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting directory {Path}", directory); - } - } - } - } - - private void DeleteFile(string path) - { - try - { - _fileSystem.DeleteFile(path); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogError(ex, "Error deleting file {Path}", path); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting file {Path}", path); - } - } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs index e4e565c64..254500ccd 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.IO; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; @@ -62,16 +62,17 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// <inheritdoc /> public bool IsLogged => true; - /// <summary> - /// Creates the triggers that define when the task will run. - /// </summary> - /// <returns>IEnumerable{BaseTaskTrigger}.</returns> + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() { return new[] { new TaskTriggerInfo { + Type = TaskTriggerInfo.TriggerStartup + }, + new TaskTriggerInfo + { Type = TaskTriggerInfo.TriggerInterval, IntervalTicks = TimeSpan.FromHours(24).Ticks } @@ -113,53 +114,14 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks cancellationToken.ThrowIfCancellationRequested(); - DeleteFile(file.FullName); + FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger); index++; } - DeleteEmptyFolders(directory); + FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger); progress.Report(100); } - - private void DeleteEmptyFolders(string parent) - { - foreach (var directory in _fileSystem.GetDirectoryPaths(parent)) - { - DeleteEmptyFolders(directory); - if (!_fileSystem.GetFileSystemEntryPaths(directory).Any()) - { - try - { - Directory.Delete(directory, false); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogError(ex, "Error deleting directory {Path}", directory); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting directory {Path}", directory); - } - } - } - } - - private void DeleteFile(string path) - { - try - { - _fileSystem.DeleteFile(path); - } - catch (UnauthorizedAccessException ex) - { - _logger.LogError(ex, "Error deleting file {Path}", path); - } - catch (IOException ex) - { - _logger.LogError(ex, "Error deleting file {Path}", path); - } - } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs index d65ac2e5e..9425b47d0 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs @@ -27,45 +27,31 @@ namespace Emby.Server.Implementations.ScheduledTasks.Triggers TaskOptions = taskOptions; } - /// <summary> - /// Occurs when [triggered]. - /// </summary> + /// <inheritdoc /> public event EventHandler<EventArgs>? Triggered; - /// <summary> - /// Gets the options of this task. - /// </summary> + /// <inheritdoc /> public TaskOptions TaskOptions { get; } - /// <summary> - /// Stars waiting for the trigger action. - /// </summary> - /// <param name="lastResult">The last result.</param> - /// <param name="logger">The logger.</param> - /// <param name="taskName">The name of the task.</param> - /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param> + /// <inheritdoc /> public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup) { DisposeTimer(); + DateTime now = DateTime.UtcNow; DateTime triggerDate; if (lastResult is null) { // Task has never been completed before - triggerDate = DateTime.UtcNow.AddHours(1); + triggerDate = now.AddHours(1); } else { - triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate }.Max().Add(_interval); - } - - if (DateTime.UtcNow > triggerDate) - { - triggerDate = DateTime.UtcNow.AddMinutes(1); + triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate, now.AddMinutes(1) }.Max().Add(_interval); } - var dueTime = triggerDate - DateTime.UtcNow; + var dueTime = triggerDate - now; var maxDueTime = TimeSpan.FromDays(7); if (dueTime > maxDueTime) @@ -76,9 +62,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Triggers _timer = new Timer(_ => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1)); } - /// <summary> - /// Stops waiting for the trigger action. - /// </summary> + /// <inheritdoc /> public void Stop() { DisposeTimer(); diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 10d5b4f97..3dda5fdee 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1202,7 +1202,8 @@ namespace Emby.Server.Implementations.Session new DtoOptions(false) { EnableImages = false - }) + }, + user.DisplayMissingEpisodes) .Where(i => !i.IsVirtualItem) .SkipWhile(i => !i.Id.Equals(episode.Id)) .ToList(); diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 34c9e86f2..c1a615666 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.TV } string? presentationUniqueKey = null; - int? limit = null; + int? limit = request.Limit; if (!request.SeriesId.IsNullOrEmpty()) { if (_libraryManager.GetItemById(request.SeriesId.Value) is Series series) diff --git a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs index 2b6b2a82c..e425000cd 100644 --- a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs +++ b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Jellyfin.Api.Constants; +using Jellyfin.Api.Extensions; using MediaBrowser.Common.Configuration; using Microsoft.AspNetCore.Authorization; @@ -24,20 +25,31 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupRequirement requirement) { + // Succeed if the startup wizard / first time setup is not complete if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted) { context.Succeed(requirement); } - else if (requirement.RequireAdmin && !context.User.IsInRole(UserRoles.Administrator)) + + // Succeed if user is admin + else if (context.User.IsInRole(UserRoles.Administrator)) + { + context.Succeed(requirement); + } + + // Fail if admin is required and user is not admin + else if (requirement.RequireAdmin) { context.Fail(); } - else + + // Succeed if admin is not required and user is not guest + else if (context.User.IsInRole(UserRoles.User)) { - // Any user-specific checks are handled in the DefaultAuthorizationHandler. context.Succeed(requirement); } + // Any user-specific checks are handled in the DefaultAuthorizationHandler. return Task.CompletedTask; } } diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs index c1343b130..d7a8c37c4 100644 --- a/Jellyfin.Api/Controllers/ItemRefreshController.cs +++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs @@ -80,7 +80,8 @@ public class ItemRefreshController : BaseJellyfinApiController || imageRefreshMode == MetadataRefreshMode.FullRefresh || replaceAllImages || replaceAllMetadata, - IsAutomated = false + IsAutomated = false, + RemoveOldMetadata = replaceAllMetadata }; _providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High); diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index b4ce343be..4001a6add 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -290,17 +290,35 @@ public class ItemUpdateController : BaseJellyfinApiController { foreach (var season in rseries.Children.OfType<Season>()) { - season.OfficialRating = request.OfficialRating; + if (!season.LockedFields.Contains(MetadataField.OfficialRating)) + { + season.OfficialRating = request.OfficialRating; + } + season.CustomRating = request.CustomRating; - season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + + if (!season.LockedFields.Contains(MetadataField.Tags)) + { + season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + } + season.OnMetadataChanged(); await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); foreach (var ep in season.Children.OfType<Episode>()) { - ep.OfficialRating = request.OfficialRating; + if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) + { + ep.OfficialRating = request.OfficialRating; + } + ep.CustomRating = request.CustomRating; - ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + + if (!ep.LockedFields.Contains(MetadataField.Tags)) + { + ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + } + ep.OnMetadataChanged(); await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } @@ -310,9 +328,18 @@ public class ItemUpdateController : BaseJellyfinApiController { foreach (var ep in season.Children.OfType<Episode>()) { - ep.OfficialRating = request.OfficialRating; + if (!ep.LockedFields.Contains(MetadataField.OfficialRating)) + { + ep.OfficialRating = request.OfficialRating; + } + ep.CustomRating = request.CustomRating; - ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + + if (!ep.LockedFields.Contains(MetadataField.Tags)) + { + ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + } + ep.OnMetadataChanged(); await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } @@ -321,9 +348,18 @@ public class ItemUpdateController : BaseJellyfinApiController { foreach (BaseItem track in album.Children) { - track.OfficialRating = request.OfficialRating; + if (!track.LockedFields.Contains(MetadataField.OfficialRating)) + { + track.OfficialRating = request.OfficialRating; + } + track.CustomRating = request.CustomRating; - track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + + if (!track.LockedFields.Contains(MetadataField.Tags)) + { + track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray(); + } + track.OnMetadataChanged(); await track.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); } diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index cd4a0a23b..d33634412 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -76,6 +76,7 @@ public class ItemsController : BaseJellyfinApiController /// <param name="hasSpecialFeature">Optional filter by items with special features.</param> /// <param name="hasTrailer">Optional filter by items with trailers.</param> /// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param> + /// <param name="indexNumber">Optional filter by index number.</param> /// <param name="parentIndexNumber">Optional filter by parent index number.</param> /// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param> /// <param name="isHd">Optional filter by items that are HD or not.</param> @@ -165,6 +166,7 @@ public class ItemsController : BaseJellyfinApiController [FromQuery] bool? hasSpecialFeature, [FromQuery] bool? hasTrailer, [FromQuery] Guid? adjacentTo, + [FromQuery] int? indexNumber, [FromQuery] int? parentIndexNumber, [FromQuery] bool? hasParentalRating, [FromQuery] bool? isHd, @@ -366,6 +368,7 @@ public class ItemsController : BaseJellyfinApiController MinCommunityRating = minCommunityRating, MinCriticRating = minCriticRating, ParentId = parentId ?? Guid.Empty, + IndexNumber = indexNumber, ParentIndexNumber = parentIndexNumber, EnableTotalRecordCount = enableTotalRecordCount, ExcludeItemIds = excludeItemIds, @@ -717,6 +720,7 @@ public class ItemsController : BaseJellyfinApiController hasSpecialFeature, hasTrailer, adjacentTo, + null, parentIndexNumber, hasParentalRating, isHd, diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index fb9f44d46..93c2393f3 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -180,7 +180,21 @@ public class LibraryStructureController : BaseJellyfinApiController // No need to start if scanning the library because it will handle it if (refreshLibrary) { - await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false); + var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase)); + if (newLib is CollectionFolder folder) + { + foreach (var child in folder.GetPhysicalFolders()) + { + await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false); + await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + } + } + else + { + // We don't know if this one can be validated individually, trigger a new validation + await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false); + } } else { @@ -319,7 +333,7 @@ public class LibraryStructureController : BaseJellyfinApiController public ActionResult UpdateLibraryOptions( [FromBody] UpdateLibraryOptionsDto request) { - var item = _libraryManager.GetItemById<CollectionFolder>(request.Id, User.GetUserId()); + var item = _libraryManager.GetItemById<CollectionFolder>(request.Id); if (item is null) { return NotFound(); diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index 4fbaafa2a..d7d0cc454 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -215,6 +215,7 @@ public class TrailersController : BaseJellyfinApiController hasSpecialFeature, hasTrailer, adjacentTo, + null, parentIndexNumber, hasParentalRating, isHd, diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 68b4b6b8b..426402667 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -231,6 +231,7 @@ public class TvShowsController : BaseJellyfinApiController var dtoOptions = new DtoOptions { Fields = fields } .AddClientFields(User) .AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes); + var shouldIncludeMissingEpisodes = (user is not null && user.DisplayMissingEpisodes) || User.GetIsApiKey(); if (seasonId.HasValue) // Season id was supplied. Get episodes by season id. { @@ -240,7 +241,7 @@ public class TvShowsController : BaseJellyfinApiController return NotFound("No season exists with Id " + seasonId); } - episodes = seasonItem.GetEpisodes(user, dtoOptions); + episodes = seasonItem.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes); } else if (season.HasValue) // Season number was supplied. Get episodes by season number { @@ -256,7 +257,7 @@ public class TvShowsController : BaseJellyfinApiController episodes = seasonItem is null ? new List<BaseItem>() - : ((Season)seasonItem).GetEpisodes(user, dtoOptions); + : ((Season)seasonItem).GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes); } else // No season number or season id was supplied. Returning all episodes. { @@ -265,7 +266,7 @@ public class TvShowsController : BaseJellyfinApiController return NotFound("Series not found"); } - episodes = series.GetEpisodes(user, dtoOptions).ToList(); + episodes = series.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes).ToList(); } // Filter after the fact in case the ui doesn't want them diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index b607e9104..a3d7f471e 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -120,7 +120,12 @@ public static class RequestHelpers internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null) { userId ??= httpContext.User.GetUserId(); - var user = userManager.GetUserById(userId.Value); + User? user = null; + if (!userId.IsNullOrEmpty()) + { + user = userManager.GetUserById(userId.Value); + } + var session = await sessionManager.LogSessionActivity( httpContext.User.GetClient(), httpContext.User.GetVersion(), diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 6cd466da0..535ef27c3 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -142,6 +142,25 @@ public static class StreamingHelpers } else { + // Enforce more restrictive transcoding profile for LiveTV due to compatability reasons + // Cap the MaxStreamingBitrate to 30Mbps, because we are unable to reliably probe source bitrate, + // which will cause the client to request extremely high bitrate that may fail the player/encoder + streamingRequest.VideoBitRate = streamingRequest.VideoBitRate > 30000000 ? 30000000 : streamingRequest.VideoBitRate; + + if (streamingRequest.SegmentContainer is not null) + { + // Remove all fmp4 transcoding profiles, because it causes playback error and/or A/V sync issues + // Notably: Some channels won't play on FireFox and LG webOS + // Some channels from HDHomerun will experience A/V sync issues + streamingRequest.SegmentContainer = "ts"; + streamingRequest.VideoCodec = "h264"; + streamingRequest.AudioCodec = "aac"; + state.SupportedVideoCodecs = ["h264"]; + state.Request.VideoCodec = "h264"; + state.SupportedAudioCodecs = ["aac"]; + state.Request.AudioCodec = "aac"; + } + var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false); mediaSource = liveStreamInfo.Item1; state.DirectStreamProvider = liveStreamInfo.Item2; diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index 5e5d52b6b..d8d1b6fa8 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -179,7 +179,7 @@ namespace Jellyfin.Server.Implementations.Devices .SelectMany(d => dbContext.DeviceOptions.Where(o => o.DeviceId == d.DeviceId).DefaultIfEmpty(), (d, o) => new { Device = d, Options = o }) .AsAsyncEnumerable(); - if (userId.HasValue) + if (!userId.IsNullOrEmpty()) { var user = _userManager.GetUserById(userId.Value); if (user is null) diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs index 3d747f2ea..a88989840 100644 --- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -16,21 +16,28 @@ public static class ServiceCollectionExtensions /// Adds the <see cref="IDbContextFactory{TContext}"/> interface to the service collection with second level caching enabled. /// </summary> /// <param name="serviceCollection">An instance of the <see cref="IServiceCollection"/> interface.</param> + /// <param name="disableSecondLevelCache">Whether second level cache disabled..</param> /// <returns>The updated service collection.</returns> - public static IServiceCollection AddJellyfinDbContext(this IServiceCollection serviceCollection) + public static IServiceCollection AddJellyfinDbContext(this IServiceCollection serviceCollection, bool disableSecondLevelCache) { - serviceCollection.AddEFSecondLevelCache(options => - options.UseMemoryCacheProvider() - .CacheAllQueries(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(10)) - .UseCacheKeyPrefix("EF_") - // Don't cache null values. Remove this optional setting if it's not necessary. - .SkipCachingResults(result => result.Value is null or EFTableRows { RowsCount: 0 })); + if (!disableSecondLevelCache) + { + serviceCollection.AddEFSecondLevelCache(options => + options.UseMemoryCacheProvider() + .CacheAllQueries(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(10)) + .UseCacheKeyPrefix("EF_") + // Don't cache null values. Remove this optional setting if it's not necessary. + .SkipCachingResults(result => result.Value is null or EFTableRows { RowsCount: 0 })); + } serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) => { var applicationPaths = serviceProvider.GetRequiredService<IApplicationPaths>(); - opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}") - .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>()); + var dbOpt = opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}"); + if (!disableSecondLevelCache) + { + dbOpt.AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>()); + } }); return serviceCollection; diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index 31c0be52f..bb32b7c20 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -81,6 +81,12 @@ public class TrickplayManager : ITrickplayManager _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace); var options = _config.Configuration.TrickplayOptions; + if (options.Interval < 1000) + { + _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value of 1000", options.Interval); + options.Interval = 1000; + } + foreach (var width in options.WidthResolutions) { cancellationToken.ThrowIfCancellationRequested(); @@ -121,6 +127,13 @@ public class TrickplayManager : ITrickplayManager return; } + var mediaPath = mediaSource.Path; + if (!File.Exists(mediaPath)) + { + _logger.LogWarning("Media not found at {Path} for item {ItemID}", mediaPath, video.Id); + return; + } + // The width has to be even, otherwise a lot of filters will not be able to sample it var actualWidth = 2 * (width / 2); @@ -139,7 +152,6 @@ public class TrickplayManager : ITrickplayManager return; } - var mediaPath = mediaSource.Path; var mediaStream = mediaSource.VideoStream; var container = mediaSource.Container; @@ -156,6 +168,7 @@ public class TrickplayManager : ITrickplayManager options.ProcessThreads, options.Qscale, options.ProcessPriority, + options.EnableKeyFrameOnlyExtraction, _encodingHelper, cancellationToken).ConfigureAwait(false); @@ -218,7 +231,7 @@ public class TrickplayManager : ITrickplayManager throw new ArgumentException("Can't create trickplay from 0 images."); } - var workDir = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid().ToString("N")); + var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(workDir); var trickplayInfo = new TrickplayInfo @@ -261,7 +274,7 @@ public class TrickplayManager : ITrickplayManager } // Update bitrate - var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tilePath).Length * 8 / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000)); + var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000m)); trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate); } diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 41f1ac351..5753e75c9 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -1,7 +1,7 @@ #pragma warning disable CA1307 -#pragma warning disable CA1309 // Use ordinal string comparison - EF can't translate this using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -47,6 +47,8 @@ namespace Jellyfin.Server.Implementations.Users private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IDictionary<Guid, User> _users; + /// <summary> /// Initializes a new instance of the <see cref="UserManager"/> class. /// </summary> @@ -84,30 +86,29 @@ namespace Jellyfin.Server.Implementations.Users _invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First(); _defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First(); _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First(); + + _users = new ConcurrentDictionary<Guid, User>(); + using var dbContext = _dbProvider.CreateDbContext(); + foreach (var user in dbContext.Users + .AsSplitQuery() + .Include(user => user.Permissions) + .Include(user => user.Preferences) + .Include(user => user.AccessSchedules) + .Include(user => user.ProfileImage) + .AsEnumerable()) + { + _users.Add(user.Id, user); + } } /// <inheritdoc/> public event EventHandler<GenericEventArgs<User>>? OnUserUpdated; /// <inheritdoc/> - public IEnumerable<User> Users - { - get - { - using var dbContext = _dbProvider.CreateDbContext(); - return GetUsersInternal(dbContext).ToList(); - } - } + public IEnumerable<User> Users => _users.Values; /// <inheritdoc/> - public IEnumerable<Guid> UsersIds - { - get - { - using var dbContext = _dbProvider.CreateDbContext(); - return dbContext.Users.Select(u => u.Id).ToList(); - } - } + public IEnumerable<Guid> UsersIds => _users.Keys; // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness @@ -123,8 +124,8 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Guid can't be empty", nameof(id)); } - using var dbContext = _dbProvider.CreateDbContext(); - return GetUsersInternal(dbContext).FirstOrDefault(u => u.Id.Equals(id)); + _users.TryGetValue(id, out var user); + return user; } /// <inheritdoc/> @@ -135,9 +136,7 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Invalid username", nameof(name)); } - using var dbContext = _dbProvider.CreateDbContext(); - return GetUsersInternal(dbContext) - .FirstOrDefault(u => string.Equals(u.Username, name)); + return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase)); } /// <inheritdoc/> @@ -202,6 +201,8 @@ namespace Jellyfin.Server.Implementations.Users user.AddDefaultPermissions(); user.AddDefaultPreferences(); + _users.Add(user.Id, user); + return user; } @@ -236,46 +237,40 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task DeleteUserAsync(Guid userId) { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + if (!_users.TryGetValue(userId, out var user)) + { + throw new ResourceNotFoundException(nameof(userId)); + } - await using (dbContext.ConfigureAwait(false)) + if (_users.Count == 1) { - var user = await dbContext.Users - .AsSingleQuery() - .Include(u => u.Permissions) - .FirstOrDefaultAsync(u => u.Id.Equals(userId)) - .ConfigureAwait(false); - if (user is null) - { - throw new ResourceNotFoundException(nameof(userId)); - } + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "The user '{0}' cannot be deleted because there must be at least one user in the system.", + user.Username)); + } - if (await dbContext.Users.CountAsync().ConfigureAwait(false) == 1) - { - throw new InvalidOperationException(string.Format( + if (user.HasPermission(PermissionKind.IsAdministrator) + && Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1) + { + throw new ArgumentException( + string.Format( CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one user in the system.", - user.Username)); - } - - if (user.HasPermission(PermissionKind.IsAdministrator) - && await dbContext.Users - .CountAsync(u => u.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value)) - .ConfigureAwait(false) == 1) - { - throw new ArgumentException( - string.Format( - CultureInfo.InvariantCulture, - "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", - user.Username), - nameof(userId)); - } + "The user '{0}' cannot be deleted because there must be at least one admin user in the system.", + user.Username), + nameof(userId)); + } + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { dbContext.Users.Remove(user); await dbContext.SaveChangesAsync().ConfigureAwait(false); - - await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); } + + _users.Remove(userId); + + await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); } /// <inheritdoc/> @@ -542,23 +537,23 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc /> public async Task InitializeAsync() { - var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. + if (_users.Any()) { - // TODO: Refactor the startup wizard so that it doesn't require a user to already exist. - if (await dbContext.Users.AnyAsync().ConfigureAwait(false)) - { - return; - } + return; + } - var defaultName = Environment.UserName; - if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) - { - defaultName = "MyJellyfinUser"; - } + var defaultName = Environment.UserName; + if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) + { + defaultName = "MyJellyfinUser"; + } - _logger.LogWarning("No users, creating one with username {UserName}", defaultName); + _logger.LogWarning("No users, creating one with username {UserName}", defaultName); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); newUser.SetPermission(PermissionKind.IsAdministrator, true); newUser.SetPermission(PermissionKind.EnableContentDeletion, true); @@ -605,9 +600,12 @@ namespace Jellyfin.Server.Implementations.Users var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var user = await GetUsersInternal(dbContext) - .FirstOrDefaultAsync(u => u.Id.Equals(userId)) - .ConfigureAwait(false) + var user = dbContext.Users + .Include(u => u.Permissions) + .Include(u => u.Preferences) + .Include(u => u.AccessSchedules) + .Include(u => u.ProfileImage) + .FirstOrDefault(u => u.Id.Equals(userId)) ?? throw new ArgumentException("No user exists with given Id!"); user.SubtitleMode = config.SubtitleMode; @@ -635,6 +633,7 @@ namespace Jellyfin.Server.Implementations.Users user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); dbContext.Update(user); + _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } } @@ -645,9 +644,12 @@ namespace Jellyfin.Server.Implementations.Users var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); await using (dbContext.ConfigureAwait(false)) { - var user = await GetUsersInternal(dbContext) - .FirstOrDefaultAsync(u => u.Id.Equals(userId)) - .ConfigureAwait(false) + var user = dbContext.Users + .Include(u => u.Permissions) + .Include(u => u.Preferences) + .Include(u => u.AccessSchedules) + .Include(u => u.ProfileImage) + .FirstOrDefault(u => u.Id.Equals(userId)) ?? throw new ArgumentException("No user exists with given Id!"); // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" @@ -708,6 +710,7 @@ namespace Jellyfin.Server.Implementations.Users user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); dbContext.Update(user); + _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } } @@ -728,6 +731,7 @@ namespace Jellyfin.Server.Implementations.Users } user.ProfileImage = null; + _users[user.Id] = user; } internal static void ThrowIfInvalidUsername(string name) @@ -874,15 +878,8 @@ namespace Jellyfin.Server.Implementations.Users private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user) { dbContext.Users.Update(user); + _users[user.Id] = user; await dbContext.SaveChangesAsync().ConfigureAwait(false); } - - private IQueryable<User> GetUsersInternal(JellyfinDbContext dbContext) - => dbContext.Users - .AsSplitQuery() - .Include(user => user.Permissions) - .Include(user => user.Preferences) - .Include(user => user.AccessSchedules) - .Include(user => user.ProfileImage); } } diff --git a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs index c9d5b54de..858df6728 100644 --- a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs @@ -35,17 +35,18 @@ public static class WebHostBuilderExtensions return builder .UseKestrel((builderContext, options) => { - var addresses = appHost.NetManager.GetAllBindInterfaces(true); + var addresses = appHost.NetManager.GetAllBindInterfaces(false); bool flagged = false; foreach (var netAdd in addresses) { - logger.LogInformation("Kestrel is listening on {Address}", IPAddress.IPv6Any.Equals(netAdd.Address) ? "All IPv6 addresses" : netAdd.Address); + var address = netAdd.Address; + logger.LogInformation("Kestrel is listening on {Address}", address.Equals(IPAddress.IPv6Any) ? "all interfaces" : address); options.Listen(netAdd.Address, appHost.HttpPort); if (appHost.ListenWithHttps) { options.Listen( - netAdd.Address, + address, appHost.HttpsPort, listenOptions => listenOptions.UseHttps(appHost.Certificate)); } @@ -54,7 +55,7 @@ public static class WebHostBuilderExtensions try { options.Listen( - netAdd.Address, + address, appHost.HttpsPort, listenOptions => listenOptions.UseHttps()); } @@ -84,6 +85,6 @@ public static class WebHostBuilderExtensions logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath); } }) - .UseStartup(_ => new Startup(appHost)); + .UseStartup(_ => new Startup(appHost, startupConfig)); } } diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs index 5311a30e4..5518d6ba8 100644 --- a/Jellyfin.Server/Helpers/StartupHelpers.cs +++ b/Jellyfin.Server/Helpers/StartupHelpers.cs @@ -60,6 +60,7 @@ public static class StartupHelpers logger.LogInformation("Log directory path: {LogDirectoryPath}", appPaths.LogDirectoryPath); logger.LogInformation("Config directory path: {ConfigurationDirectoryPath}", appPaths.ConfigurationDirectoryPath); logger.LogInformation("Cache path: {CachePath}", appPaths.CachePath); + logger.LogInformation("Temp directory path: {TempDirPath}", appPaths.TempDirectory); logger.LogInformation("Web resources path: {WebPath}", appPaths.WebPath); logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath); } diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index 44aa43044..81fecc9a1 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -44,7 +44,8 @@ namespace Jellyfin.Server.Migrations typeof(Routines.FixPlaylistOwner), typeof(Routines.MigrateRatingLevels), typeof(Routines.AddDefaultCastReceivers), - typeof(Routines.UpdateDefaultPluginRepository) + typeof(Routines.UpdateDefaultPluginRepository), + typeof(Routines.FixAudioData), }; /// <summary> diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs new file mode 100644 index 000000000..a20253369 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs @@ -0,0 +1,106 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines +{ + /// <summary> + /// Fixes the data column of audio types to be deserializable. + /// </summary> + internal class FixAudioData : IMigrationRoutine + { + private const string DbFilename = "library.db"; + private readonly ILogger<FixAudioData> _logger; + private readonly IServerApplicationPaths _applicationPaths; + private readonly IItemRepository _itemRepository; + + public FixAudioData( + IServerApplicationPaths applicationPaths, + ILoggerFactory loggerFactory, + IItemRepository itemRepository) + { + _applicationPaths = applicationPaths; + _itemRepository = itemRepository; + _logger = loggerFactory.CreateLogger<FixAudioData>(); + } + + /// <inheritdoc/> + public Guid Id => Guid.Parse("{CF6FABC2-9FBE-4933-84A5-FFE52EF22A58}"); + + /// <inheritdoc/> + public string Name => "FixAudioData"; + + /// <inheritdoc/> + public bool PerformOnNewInstall => false; + + /// <inheritdoc/> + public void Perform() + { + var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); + + // Back up the database before modifying any entries + for (int i = 1; ; i++) + { + var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i); + if (!File.Exists(bakPath)) + { + try + { + _logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath); + File.Copy(dbPath, bakPath); + _logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath); + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath); + throw; + } + } + } + + _logger.LogInformation("Backfilling audio lyrics data to database."); + var startIndex = 0; + var records = _itemRepository.GetCount(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Audio], + }); + + while (startIndex < records) + { + var results = _itemRepository.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Audio], + StartIndex = startIndex, + Limit = 5000, + SkipDeserialization = true + }) + .Cast<Audio>() + .ToList(); + + foreach (var audio in results) + { + var lyricMediaStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric).Select(s => s.Path).ToList(); + if (lyricMediaStreams.Count > 0) + { + audio.HasLyrics = true; + audio.LyricFiles = lyricMediaStreams; + } + } + + _itemRepository.SaveItems(results, CancellationToken.None); + startIndex += results.Count; + _logger.LogInformation("Backfilled data for {UpdatedRecords} of {TotalRecords} audio records", startIndex, records); + } + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index 4fee88b68..808c5e33b 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -67,7 +67,7 @@ namespace Jellyfin.Server.Migrations.Routines using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) { connection.Open(); - var dbContext = _provider.CreateDbContext(); + using var dbContext = _provider.CreateDbContext(); var queryResult = connection.Query("SELECT * FROM LocalUsersv2"); diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index e9fb3e4c2..2ff377403 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -40,15 +40,18 @@ namespace Jellyfin.Server { private readonly CoreAppHost _serverApplicationHost; private readonly IServerConfigurationManager _serverConfigurationManager; + private readonly IConfiguration _startupConfig; /// <summary> /// Initializes a new instance of the <see cref="Startup" /> class. /// </summary> /// <param name="appHost">The server application host.</param> - public Startup(CoreAppHost appHost) + /// <param name="startupConfig">The server startupConfig.</param> + public Startup(CoreAppHost appHost, IConfiguration startupConfig) { _serverApplicationHost = appHost; _serverConfigurationManager = appHost.ConfigurationManager; + _startupConfig = startupConfig; } /// <summary> @@ -67,7 +70,7 @@ namespace Jellyfin.Server // TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371 services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>(); services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration()); - services.AddJellyfinDbContext(); + services.AddJellyfinDbContext(_startupConfig.GetSqliteSecondLevelCacheDisabled()); services.AddJellyfinApiSwagger(); // configure custom legacy authentication diff --git a/MediaBrowser.Common/Net/NetworkConstants.cs b/MediaBrowser.Common/Net/NetworkConstants.cs index b18058fa9..ccef5d271 100644 --- a/MediaBrowser.Common/Net/NetworkConstants.cs +++ b/MediaBrowser.Common/Net/NetworkConstants.cs @@ -59,6 +59,11 @@ public static class NetworkConstants public static readonly IPNetwork IPv4RFC1918PrivateClassC = new IPNetwork(IPAddress.Parse("192.168.0.0"), 16); /// <summary> + /// IPv4 Link-Local as defined in RFC 3927. + /// </summary> + public static readonly IPNetwork IPv4RFC3927LinkLocal = new IPNetwork(IPAddress.Parse("169.254.0.0"), 16); + + /// <summary> /// IPv6 loopback as defined in RFC 4291. /// </summary> public static readonly IPNetwork IPv6RFC4291Loopback = new IPNetwork(IPAddress.IPv6Loopback, 128); diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index 5b3349108..a0aae8769 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -169,8 +169,7 @@ namespace MediaBrowser.Controller.Entities.Audio var childUpdateType = ItemUpdateType.None; - // Refresh songs only and not m3u files in album folder - foreach (var item in items.OfType<Audio>()) + foreach (var item in items) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 22793206e..68ae67d05 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -752,9 +752,6 @@ namespace MediaBrowser.Controller.Entities public virtual bool SupportsAncestors => true; [JsonIgnore] - public virtual bool StopRefreshIfLocalMetadataFound => true; - - [JsonIgnore] protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol; [JsonIgnore] @@ -1952,14 +1949,15 @@ namespace MediaBrowser.Controller.Entities return; } - // Remove it from the item - RemoveImage(info); - + // Remove from file system if (info.IsLocalFile) { FileSystem.DeleteFile(info.Path); } + // Remove from item + RemoveImage(info); + await UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); } @@ -2499,11 +2497,6 @@ namespace MediaBrowser.Controller.Entities return new[] { Id }; } - public virtual List<ExternalUrl> GetRelatedUrls() - { - return new List<ExternalUrl>(); - } - public virtual double? GetRefreshProgress() { return null; diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 0f2ec1f8f..b2e5d7263 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -364,15 +365,23 @@ namespace MediaBrowser.Controller.Entities if (IsFileProtocol) { - IEnumerable<BaseItem> nonCachedChildren; + IEnumerable<BaseItem> nonCachedChildren = []; try { nonCachedChildren = GetNonCachedChildren(directoryService); } + catch (IOException ex) + { + Logger.LogError(ex, "Error retrieving children from file system"); + } + catch (SecurityException ex) + { + Logger.LogError(ex, "Error retrieving children from file system"); + } catch (Exception ex) { - Logger.LogError(ex, "Error retrieving children folder"); + Logger.LogError(ex, "Error retrieving children"); return; } diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 555dd050c..1461a3680 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -51,6 +51,7 @@ namespace MediaBrowser.Controller.Entities TrailerTypes = Array.Empty<TrailerType>(); VideoTypes = Array.Empty<VideoType>(); Years = Array.Empty<int>(); + SkipDeserialization = false; } public InternalItemsQuery(User? user) @@ -358,6 +359,8 @@ namespace MediaBrowser.Controller.Entities public string? SeriesTimerId { get; set; } + public bool SkipDeserialization { get; set; } + public void SetUser(User user) { MaxParentalRating = user.MaxParentalAgeRating; diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 81f6248fa..710b05e7f 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -45,9 +45,6 @@ namespace MediaBrowser.Controller.Entities.Movies set => TmdbCollectionName = value; } - [JsonIgnore] - public override bool StopRefreshIfLocalMetadataFound => false; - public override double GetDefaultPrimaryImageAspectRatio() { // hack for tv plugins @@ -124,23 +121,5 @@ namespace MediaBrowser.Controller.Entities.Movies return hasChanges; } - - /// <inheritdoc /> - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/movies/{0}", imdbId) - }); - } - - return list; - } } } diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 37e241414..5c54f014c 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -344,22 +344,5 @@ namespace MediaBrowser.Controller.Entities.TV return hasChanges; } - - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/episodes/{0}", imdbId) - }); - } - - return list; - } } } diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs index c29cefc15..083f12746 100644 --- a/MediaBrowser.Controller/Entities/TV/Season.cs +++ b/MediaBrowser.Controller/Entities/TV/Season.cs @@ -159,7 +159,7 @@ namespace MediaBrowser.Controller.Entities.TV Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager); - var items = GetEpisodes(user, query.DtoOptions).Where(filter); + var items = GetEpisodes(user, query.DtoOptions, true).Where(filter); return PostFilterAndSort(items, query, false); } @@ -169,30 +169,31 @@ namespace MediaBrowser.Controller.Entities.TV /// </summary> /// <param name="user">The user.</param> /// <param name="options">The options to use.</param> + /// <param name="shouldIncludeMissingEpisodes">If missing episodes should be included.</param> /// <returns>Set of episodes.</returns> - public List<BaseItem> GetEpisodes(User user, DtoOptions options) + public List<BaseItem> GetEpisodes(User user, DtoOptions options, bool shouldIncludeMissingEpisodes) { - return GetEpisodes(Series, user, options); + return GetEpisodes(Series, user, options, shouldIncludeMissingEpisodes); } - public List<BaseItem> GetEpisodes(Series series, User user, DtoOptions options) + public List<BaseItem> GetEpisodes(Series series, User user, DtoOptions options, bool shouldIncludeMissingEpisodes) { - return GetEpisodes(series, user, null, options); + return GetEpisodes(series, user, null, options, shouldIncludeMissingEpisodes); } - public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options) + public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes) { - return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options); + return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes); } public List<BaseItem> GetEpisodes() { - return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true)); + return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true); } public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) { - return GetEpisodes(user, new DtoOptions(true)); + return GetEpisodes(user, new DtoOptions(true), true); } protected override bool GetBlockUnratedValue(User user) diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index a49c1609d..69b04a927 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -28,7 +28,6 @@ namespace MediaBrowser.Controller.Entities.TV public Series() { AirDays = Array.Empty<DayOfWeek>(); - SeasonNames = new Dictionary<int, string>(); } public DayOfWeek[] AirDays { get; set; } @@ -36,9 +35,6 @@ namespace MediaBrowser.Controller.Entities.TV public string AirTime { get; set; } [JsonIgnore] - public Dictionary<int, string> SeasonNames { get; set; } - - [JsonIgnore] public override bool SupportsAddingToPlaylist => true; [JsonIgnore] @@ -73,9 +69,6 @@ namespace MediaBrowser.Controller.Entities.TV /// <value>The status.</value> public SeriesStatus? Status { get; set; } - [JsonIgnore] - public override bool StopRefreshIfLocalMetadataFound => false; - public override double GetDefaultPrimaryImageAspectRatio() { double value = 2; @@ -257,7 +250,7 @@ namespace MediaBrowser.Controller.Entities.TV return LibraryManager.GetItemsResult(query); } - public IEnumerable<BaseItem> GetEpisodes(User user, DtoOptions options) + public IEnumerable<BaseItem> GetEpisodes(User user, DtoOptions options, bool shouldIncludeMissingEpisodes) { var seriesKey = GetUniqueSeriesKey(this); @@ -267,10 +260,10 @@ namespace MediaBrowser.Controller.Entities.TV SeriesPresentationUniqueKey = seriesKey, IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Season }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, - DtoOptions = options + DtoOptions = options, }; - if (user is null || !user.DisplayMissingEpisodes) + if (!shouldIncludeMissingEpisodes) { query.IsMissing = false; } @@ -280,7 +273,7 @@ namespace MediaBrowser.Controller.Entities.TV var allSeriesEpisodes = allItems.OfType<Episode>().ToList(); var allEpisodes = allItems.OfType<Season>() - .SelectMany(i => i.GetEpisodes(this, user, allSeriesEpisodes, options)) + .SelectMany(i => i.GetEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes)) .Reverse(); // Specials could appear twice based on above - once in season 0, once in the aired season @@ -292,8 +285,7 @@ namespace MediaBrowser.Controller.Entities.TV public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken) { - // Refresh bottom up, children first, then the boxset - // By then hopefully the movies within will have Tmdb collection values + // Refresh bottom up, seasons and episodes first, then the series var items = GetRecursiveChildren(); var totalItems = items.Count; @@ -356,41 +348,32 @@ namespace MediaBrowser.Controller.Entities.TV await ProviderManager.RefreshSingleItem(this, refreshOptions, cancellationToken).ConfigureAwait(false); } - public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options) + public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options, bool shouldIncludeMissingEpisodes) { - var queryFromSeries = ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons; - - // add optimization when this setting is not enabled - var seriesKey = queryFromSeries ? - GetUniqueSeriesKey(this) : - GetUniqueSeriesKey(parentSeason); - var query = new InternalItemsQuery(user) { - AncestorWithPresentationUniqueKey = queryFromSeries ? null : seriesKey, - SeriesPresentationUniqueKey = queryFromSeries ? seriesKey : null, + AncestorWithPresentationUniqueKey = null, + SeriesPresentationUniqueKey = GetUniqueSeriesKey(this), IncludeItemTypes = new[] { BaseItemKind.Episode }, OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }, DtoOptions = options }; - if (user is not null) + + if (!shouldIncludeMissingEpisodes) { - if (!user.DisplayMissingEpisodes) - { - query.IsMissing = false; - } + query.IsMissing = false; } var allItems = LibraryManager.GetItemList(query); - return GetSeasonEpisodes(parentSeason, user, allItems, options); + return GetSeasonEpisodes(parentSeason, user, allItems, options, shouldIncludeMissingEpisodes); } - public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, IEnumerable<BaseItem> allSeriesEpisodes, DtoOptions options) + public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, IEnumerable<BaseItem> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes) { if (allSeriesEpisodes is null) { - return GetSeasonEpisodes(parentSeason, user, options); + return GetSeasonEpisodes(parentSeason, user, options, shouldIncludeMissingEpisodes); } var episodes = FilterEpisodesBySeason(allSeriesEpisodes, parentSeason, ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons); @@ -499,22 +482,5 @@ namespace MediaBrowser.Controller.Entities.TV return hasChanges; } - - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/shows/{0}", imdbId) - }); - } - - return list; - } } } diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs index 1c558d419..939709215 100644 --- a/MediaBrowser.Controller/Entities/Trailer.cs +++ b/MediaBrowser.Controller/Entities/Trailer.cs @@ -23,9 +23,6 @@ namespace MediaBrowser.Controller.Entities TrailerTypes = Array.Empty<TrailerType>(); } - [JsonIgnore] - public override bool StopRefreshIfLocalMetadataFound => false; - public TrailerType[] TrailerTypes { get; set; } public override double GetDefaultPrimaryImageAspectRatio() @@ -83,22 +80,5 @@ namespace MediaBrowser.Controller.Entities return hasChanges; } - - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/movies/{0}", imdbId) - }); - } - - return list; - } } } diff --git a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs index 6c58064ce..7dfda73bf 100644 --- a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs +++ b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs @@ -65,6 +65,11 @@ namespace MediaBrowser.Controller.Extensions public const string SqliteCacheSizeKey = "sqlite:cacheSize"; /// <summary> + /// Disable second level cache of sqlite. + /// </summary> + public const string SqliteDisableSecondLevelCacheKey = "sqlite:disableSecondLevelCache"; + + /// <summary> /// Gets a value indicating whether the application should host static web content from the <see cref="IConfiguration"/>. /// </summary> /// <param name="configuration">The configuration to retrieve the value from.</param> @@ -128,5 +133,15 @@ namespace MediaBrowser.Controller.Extensions /// <returns>The sqlite cache size.</returns> public static int? GetSqliteCacheSize(this IConfiguration configuration) => configuration.GetValue<int?>(SqliteCacheSizeKey); + + /// <summary> + /// Gets whether second level cache disabled from the <see cref="IConfiguration" />. + /// </summary> + /// <param name="configuration">The configuration to read the setting from.</param> + /// <returns>Whether second level cache disabled.</returns> + public static bool GetSqliteSecondLevelCacheDisabled(this IConfiguration configuration) + { + return configuration.GetValue<bool>(SqliteDisableSecondLevelCacheKey); + } } } diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs new file mode 100644 index 000000000..1a33c3aa8 --- /dev/null +++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Linq; +using MediaBrowser.Model.IO; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.IO; + +/// <summary> +/// Helper methods for file system management. +/// </summary> +public static class FileSystemHelper +{ + /// <summary> + /// Deletes the file. + /// </summary> + /// <param name="fileSystem">The fileSystem.</param> + /// <param name="path">The path.</param> + /// <param name="logger">The logger.</param> + public static void DeleteFile(IFileSystem fileSystem, string path, ILogger logger) + { + try + { + fileSystem.DeleteFile(path); + } + catch (UnauthorizedAccessException ex) + { + logger.LogError(ex, "Error deleting file {Path}", path); + } + catch (IOException ex) + { + logger.LogError(ex, "Error deleting file {Path}", path); + } + } + + /// <summary> + /// Recursively delete empty folders. + /// </summary> + /// <param name="fileSystem">The fileSystem.</param> + /// <param name="path">The path.</param> + /// <param name="logger">The logger.</param> + public static void DeleteEmptyFolders(IFileSystem fileSystem, string path, ILogger logger) + { + foreach (var directory in fileSystem.GetDirectoryPaths(path)) + { + DeleteEmptyFolders(fileSystem, directory, logger); + if (!fileSystem.GetFileSystemEntryPaths(directory).Any()) + { + try + { + Directory.Delete(directory, false); + } + catch (UnauthorizedAccessException ex) + { + logger.LogError(ex, "Error deleting directory {Path}", directory); + } + catch (IOException ex) + { + logger.LogError(ex, "Error deleting directory {Path}", directory); + } + } + } + } +} diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index 37703ceee..b802b7e6e 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -149,6 +149,14 @@ namespace MediaBrowser.Controller.Library /// <returns>Task.</returns> Task ValidateMediaLibrary(IProgress<double> progress, CancellationToken cancellationToken); + /// <summary> + /// Reloads the root media folder. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="removeRoot">Is remove the library itself allowed.</param> + /// <returns>Task.</returns> + Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false); + Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false); /// <summary> diff --git a/MediaBrowser.Controller/LiveTv/LiveTvConflictException.cs b/MediaBrowser.Controller/LiveTv/LiveTvConflictException.cs index 881c42c73..3a062a467 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvConflictException.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvConflictException.cs @@ -9,10 +9,6 @@ namespace MediaBrowser.Controller.LiveTv /// </summary> public class LiveTvConflictException : Exception { - public LiveTvConflictException() - { - } - public LiveTvConflictException(string message) : base(message) { diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs index 05540d490..2ac6f9963 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs @@ -254,25 +254,5 @@ namespace MediaBrowser.Controller.LiveTv return name; } - - public override List<ExternalUrl> GetRelatedUrls() - { - var list = base.GetRelatedUrls(); - - var imdbId = this.GetProviderId(MetadataProvider.Imdb); - if (!string.IsNullOrEmpty(imdbId)) - { - if (IsMovie) - { - list.Add(new ExternalUrl - { - Name = "Trakt", - Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/movies/{0}", imdbId) - }); - } - } - - return list; - } } } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index d6907fdf9..b175dc403 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading; @@ -55,6 +56,7 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minKerneli915Hang = new Version(5, 18); private readonly Version _maxKerneli915Hang = new Version(6, 1, 3); private readonly Version _minFixedKernel60i915Hang = new Version(6, 0, 18); + private readonly Version _minKernelVersionAmdVkFmtModifier = new Version(5, 15); private readonly Version _minFFmpegImplictHwaccel = new Version(6, 0); private readonly Version _minFFmpegHwaUnsafeOutput = new Version(6, 0); @@ -274,6 +276,21 @@ namespace MediaBrowser.Controller.MediaEncoding && _mediaEncoder.SupportsFilter("scale_vt"); } + private bool IsSwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) + { + if (state.VideoStream is null + || !options.EnableTonemapping + || GetVideoColorBitDepth(state) != 10 + || !_mediaEncoder.SupportsFilter("tonemapx") + || !(string.Equals(state.VideoStream?.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) || string.Equals(state.VideoStream?.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return state.VideoStream.VideoRange == VideoRange.HDR + && state.VideoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG or VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithHLG; + } + private bool IsHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { if (state.VideoStream is null @@ -680,16 +697,6 @@ namespace MediaBrowser.Controller.MediaEncoding return -1; } - public string GetInputPathArgument(EncodingJobInfo state) - { - return state.MediaSource.VideoType switch - { - VideoType.Dvd => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistVobFiles(state.MediaPath, null).ToList(), state.MediaSource), - VideoType.BluRay => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistM2tsFiles(state.MediaPath).ToList(), state.MediaSource), - _ => _mediaEncoder.GetInputArgument(state.MediaPath, state.MediaSource) - }; - } - /// <summary> /// Gets the audio encoder. /// </summary> @@ -1005,7 +1012,8 @@ namespace MediaBrowser.Controller.MediaEncoding Environment.SetEnvironmentVariable("AMD_DEBUG", "noefc"); if (IsVulkanFullSupported() - && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop) + && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop + && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier) { args.Append(GetDrmDeviceArgs(options.VaapiDevice, DrmAlias)); args.Append(GetVaapiDeviceArgs(null, null, null, DrmAlias, VaapiAlias)); @@ -1195,15 +1203,20 @@ namespace MediaBrowser.Controller.MediaEncoding if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) { - var tmpConcatPath = Path.Join(_configurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat"); - _mediaEncoder.GenerateConcatConfig(state.MediaSource, tmpConcatPath); - arg.Append(" -f concat -safe 0 -i ") - .Append(tmpConcatPath); + var concatFilePath = Path.Join(_configurationManager.CommonApplicationPaths.CachePath, "concat", state.MediaSource.Id + ".concat"); + if (!File.Exists(concatFilePath)) + { + _mediaEncoder.GenerateConcatConfig(state.MediaSource, concatFilePath); + } + + arg.Append(" -f concat -safe 0 -i \"") + .Append(concatFilePath) + .Append("\" "); } else { arg.Append(" -i ") - .Append(GetInputPathArgument(state)); + .Append(_mediaEncoder.GetInputPathArgument(state)); } // sub2video for external graphical subtitles @@ -1215,8 +1228,8 @@ namespace MediaBrowser.Controller.MediaEncoding var subtitlePath = state.SubtitleStream.Path; var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan()); - if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase) - || subtitleExtension.Equals(".sup", StringComparison.OrdinalIgnoreCase)) + // dvdsub/vobsub graphical subtitles use .sub+.idx pairs + if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase)) { var idxFile = Path.ChangeExtension(subtitlePath, ".idx"); if (File.Exists(idxFile)) @@ -2083,6 +2096,18 @@ namespace MediaBrowser.Controller.MediaEncoding profile = "constrained_high"; } + if (string.Equals(videoEncoder, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase) + && profile.Contains("constrainedbaseline", StringComparison.OrdinalIgnoreCase)) + { + profile = "constrained_baseline"; + } + + if (string.Equals(videoEncoder, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase) + && profile.Contains("constrainedhigh", StringComparison.OrdinalIgnoreCase)) + { + profile = "constrained_high"; + } + if (!string.IsNullOrEmpty(profile)) { // Currently there's no profile option in av1_nvenc encoder @@ -2316,7 +2341,11 @@ namespace MediaBrowser.Controller.MediaEncoding if (request.VideoBitRate.HasValue && (!videoStream.BitRate.HasValue || videoStream.BitRate.Value > request.VideoBitRate.Value)) { - return false; + // For LiveTV that has no bitrate, let's try copy if other conditions are met + if (string.IsNullOrWhiteSpace(request.LiveStreamId) || videoStream.BitRate.HasValue) + { + return false; + } } var maxBitDepth = state.GetRequestedVideoBitDepth(videoStream.Codec); @@ -2629,10 +2658,14 @@ namespace MediaBrowser.Controller.MediaEncoding && state.AudioStream.Channels.HasValue && state.AudioStream.Channels.Value == 6) { + if (!encodingOptions.DownMixAudioBoost.Equals(1)) + { + filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture)); + } + switch (encodingOptions.DownMixStereoAlgorithm) { case DownMixStereoAlgorithms.Dave750: - filters.Add("volume=4.25"); filters.Add("pan=stereo|c0=0.5*c2+0.707*c0+0.707*c4+0.5*c3|c1=0.5*c2+0.707*c1+0.707*c5+0.5*c3"); break; case DownMixStereoAlgorithms.NightmodeDialogue: @@ -2640,11 +2673,6 @@ namespace MediaBrowser.Controller.MediaEncoding break; case DownMixStereoAlgorithms.None: default: - if (!encodingOptions.DownMixAudioBoost.Equals(1)) - { - filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture)); - } - break; } } @@ -2771,7 +2799,13 @@ namespace MediaBrowser.Controller.MediaEncoding if (time > 0) { - seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(time)); + // For direct streaming/remuxing, we seek at the exact position of the keyframe + // However, ffmpeg will seek to previous keyframe when the exact time is the input + // Workaround this by adding 0.5s offset to the seeking time to get the exact keyframe on most videos. + // This will help subtitle syncing. + var isHlsRemuxing = state.IsVideoRequest && state.TranscodingType is TranscodingJobType.Hls && IsCopyCodec(state.OutputVideoCodec); + var seekTick = isHlsRemuxing ? time + 5000000L : time; + seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(seekTick)); if (state.IsVideoRequest) { @@ -3155,7 +3189,9 @@ namespace MediaBrowser.Controller.MediaEncoding int? requestedMaxHeight) { var isV4l2 = string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase); + var isMjpeg = videoEncoder is not null && videoEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var scaleVal = isV4l2 ? 64 : 2; + var targetAr = isMjpeg ? "(a*sar)" : "a"; // manually calculate AR when using mjpeg encoder // If fixed dimensions were supplied if (requestedWidth.HasValue && requestedHeight.HasValue) @@ -3184,10 +3220,11 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - @"scale=trunc(min(max(iw\,ih*a)\,min({0}\,{1}*a))/{2})*{2}:trunc(min(max(iw/a\,ih)\,min({0}/a\,{1}))/2)*2", + @"scale=trunc(min(max(iw\,ih*{3})\,min({0}\,{1}*{3}))/{2})*{2}:trunc(min(max(iw/{3}\,ih)\,min({0}/{3}\,{1}))/2)*2", maxWidthParam, maxHeightParam, - scaleVal); + scaleVal, + targetAr); } // If a fixed width was requested @@ -3203,8 +3240,9 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - "scale={0}:trunc(ow/a/2)*2", - widthParam); + "scale={0}:trunc(ow/{1}/2)*2", + widthParam, + targetAr); } // If a fixed height was requested @@ -3214,9 +3252,10 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - "scale=trunc(oh*a/{1})*{1}:{0}", + "scale=trunc(oh*{2}/{1})*{1}:{0}", heightParam, - scaleVal); + scaleVal, + targetAr); } // If a max width was requested @@ -3226,9 +3265,10 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - @"scale=trunc(min(max(iw\,ih*a)\,{0})/{1})*{1}:trunc(ow/a/2)*2", + @"scale=trunc(min(max(iw\,ih*{2})\,{0})/{1})*{1}:trunc(ow/{2}/2)*2", maxWidthParam, - scaleVal); + scaleVal, + targetAr); } // If a max height was requested @@ -3238,9 +3278,10 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - @"scale=trunc(oh*a/{1})*{1}:min(max(iw/a\,ih)\,{0})", + @"scale=trunc(oh*{2}/{1})*{1}:min(max(iw/{2}\,ih)\,{0})", maxHeightParam, - scaleVal); + scaleVal, + targetAr); } return string.Empty; @@ -3495,6 +3536,7 @@ namespace MediaBrowser.Controller.MediaEncoding var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); var doDeintH2645 = doDeintH264 || doDeintHevc; + var doToneMap = IsSwTonemapAvailable(state, options); var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; @@ -3503,7 +3545,7 @@ namespace MediaBrowser.Controller.MediaEncoding /* Make main filters for video stream */ var mainFilters = new List<string>(); - mainFilters.Add(GetOverwriteColorPropertiesParam(state, false)); + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doToneMap)); // INPUT sw surface(memory/copy-back from vram) // sw deint @@ -3526,11 +3568,31 @@ namespace MediaBrowser.Controller.MediaEncoding // sw scale mainFilters.Add(swScaleFilter); - mainFilters.Add("format=" + outFormat); - // sw tonemap <= TODO: finsh the fast tonemap filter + // sw tonemap <= TODO: finish dovi tone mapping + + if (doToneMap) + { + var tonemapArgs = $"tonemapx=tonemap={options.TonemappingAlgorithm}:desat={options.TonemappingDesat}:peak={options.TonemappingPeak}:t=bt709:m=bt709:p=bt709:format={outFormat}"; + + if (options.TonemappingParam != 0) + { + tonemapArgs += $":param={options.TonemappingParam}"; + } + + if (string.Equals(options.TonemappingRange, "tv", StringComparison.OrdinalIgnoreCase) + || string.Equals(options.TonemappingRange, "pc", StringComparison.OrdinalIgnoreCase)) + { + tonemapArgs += $":range={options.TonemappingRange}"; + } - // OUTPUT yuv420p/nv12 surface(memory) + mainFilters.Add(tonemapArgs); + } + else + { + // OUTPUT yuv420p/nv12 surface(memory) + mainFilters.Add("format=" + outFormat); + } /* Make sub and overlay filters for subtitle stream */ var subFilters = new List<string>(); @@ -4285,6 +4347,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // map from qsv to vaapi. mainFilters.Add("hwmap=derive_device=vaapi"); + mainFilters.Add("format=vaapi"); } var tonemapFilter = GetHwTonemapFilter(options, "vaapi", "nv12"); @@ -4294,6 +4357,7 @@ namespace MediaBrowser.Controller.MediaEncoding { // map from vaapi to qsv. mainFilters.Add("hwmap=derive_device=qsv"); + mainFilters.Add("format=qsv"); } } @@ -4468,7 +4532,8 @@ namespace MediaBrowser.Controller.MediaEncoding // prefered vaapi + vulkan filters pipeline if (_mediaEncoder.IsVaapiDeviceAmd && isVaapiVkSupported - && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop) + && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop + && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier) { // AMD radeonsi path(targeting Polaris/gfx8+), with extra vulkan tonemap and overlay support. return GetAmdVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder); @@ -5685,16 +5750,29 @@ namespace MediaBrowser.Controller.MediaEncoding { var bitDepth = GetVideoColorBitDepth(state); - // Only HEVC, VP9 and AV1 formats have 10-bit hardware decoder support now. + // Only HEVC, VP9 and AV1 formats have 10-bit hardware decoder support for most platforms if (bitDepth == 10 && !(string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream.Codec, "h265", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))) { - // One exception is that RKMPP decoder can handle H.264 High 10. - if (!(string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase) - && string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase))) + // RKMPP has H.264 Hi10P decoder + bool hasHardwareHi10P = string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase); + + // VideoToolbox on Apple Silicon has H.264 Hi10P mode enabled after macOS 14.6 + if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase)) + { + var ver = Environment.OSVersion.Version; + var arch = RuntimeInformation.OSArchitecture; + if (arch.Equals(Architecture.Arm64) && ver >= new Version(14, 6)) + { + hasHardwareHi10P = true; + } + } + + if (!hasHardwareHi10P + && string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)) { return null; } diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index e696fa52c..038c6c7f6 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -153,6 +153,7 @@ namespace MediaBrowser.Controller.MediaEncoding /// <param name="threads">The input/output thread count for ffmpeg.</param> /// <param name="qualityScale">The qscale value for ffmpeg.</param> /// <param name="priority">The process priority for the ffmpeg process.</param> + /// <param name="enableKeyFrameOnlyExtraction">Whether to only extract key frames.</param> /// <param name="encodingHelper">EncodingHelper instance.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Directory where images where extracted. A given image made before another will always be named with a lower number.</returns> @@ -168,6 +169,7 @@ namespace MediaBrowser.Controller.MediaEncoding int? threads, int? qualityScale, ProcessPriorityClass? priority, + bool enableKeyFrameOnlyExtraction, EncodingHelper encodingHelper, CancellationToken cancellationToken); @@ -246,6 +248,21 @@ namespace MediaBrowser.Controller.MediaEncoding IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path); /// <summary> + /// Gets the input path argument from <see cref="EncodingJobInfo"/>. + /// </summary> + /// <param name="state">The <see cref="EncodingJobInfo"/>.</param> + /// <returns>The input path argument.</returns> + string GetInputPathArgument(EncodingJobInfo state); + + /// <summary> + /// Gets the input path argument. + /// </summary> + /// <param name="path">The item path.</param> + /// <param name="mediaSource">The <see cref="MediaSourceInfo"/>.</param> + /// <returns>The input path argument.</returns> + string GetInputPathArgument(string path, MediaSourceInfo mediaSource); + + /// <summary> /// Generates a FFmpeg concat config for the source. /// </summary> /// <param name="source">The <see cref="MediaSourceInfo"/>.</param> diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs index 6fc9a7e1b..45aefacf6 100644 --- a/MediaBrowser.Controller/Playlists/Playlist.cs +++ b/MediaBrowser.Controller/Playlists/Playlist.cs @@ -166,7 +166,7 @@ namespace MediaBrowser.Controller.Playlists return base.GetChildren(user, true, query); } - public static IReadOnlyList<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> inputItems, User user, DtoOptions options) + public static IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<BaseItem> inputItems, User user, DtoOptions options) { if (user is not null) { @@ -177,14 +177,14 @@ namespace MediaBrowser.Controller.Playlists foreach (var item in inputItems) { - var playlistItems = GetPlaylistItems(item, user, playlistMediaType, options); + var playlistItems = GetPlaylistItems(item, user, options); list.AddRange(playlistItems); } return list; } - private static IEnumerable<BaseItem> GetPlaylistItems(BaseItem item, User user, MediaType mediaType, DtoOptions options) + private static IEnumerable<BaseItem> GetPlaylistItems(BaseItem item, User user, DtoOptions options) { if (item is MusicGenre musicGenre) { @@ -216,7 +216,7 @@ namespace MediaBrowser.Controller.Playlists { Recursive = true, IsFolder = false, - MediaTypes = [mediaType], + MediaTypes = [MediaType.Audio, MediaType.Video], EnableTotalRecordCount = false, DtoOptions = options }; diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index 7fe2f64af..474f09dc5 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -28,6 +28,22 @@ namespace MediaBrowser.Controller.Providers return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem); } + public List<FileSystemMetadata> GetDirectories(string path) + { + var list = new List<FileSystemMetadata>(); + var items = GetFileSystemEntries(path); + for (var i = 0; i < items.Length; i++) + { + var item = items[i]; + if (item.IsDirectory) + { + list.Add(item); + } + } + + return list; + } + public List<FileSystemMetadata> GetFiles(string path) { var list = new List<FileSystemMetadata>(); @@ -46,10 +62,22 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata? GetFile(string path) { + var entry = GetFileSystemEntry(path); + return entry is not null && !entry.IsDirectory ? entry : null; + } + + public FileSystemMetadata? GetDirectory(string path) + { + var entry = GetFileSystemEntry(path); + return entry is not null && entry.IsDirectory ? entry : null; + } + + public FileSystemMetadata? GetFileSystemEntry(string path) + { if (!_fileCache.TryGetValue(path, out var result)) { - var file = _fileSystem.GetFileInfo(path); - if (file.Exists) + var file = _fileSystem.GetFileSystemInfo(path); + if (file?.Exists ?? false) { result = file; _fileCache.TryAdd(path, result); diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index 6d7550ab5..1babf73af 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -9,10 +9,16 @@ namespace MediaBrowser.Controller.Providers { FileSystemMetadata[] GetFileSystemEntries(string path); + List<FileSystemMetadata> GetDirectories(string path); + List<FileSystemMetadata> GetFiles(string path); FileSystemMetadata? GetFile(string path); + FileSystemMetadata? GetDirectory(string path); + + FileSystemMetadata? GetFileSystemEntry(string path); + IReadOnlyList<string> GetFilePaths(string path); IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false); diff --git a/MediaBrowser.Controller/Providers/IExternalId.cs b/MediaBrowser.Controller/Providers/IExternalId.cs index 0d847520d..f451eac6d 100644 --- a/MediaBrowser.Controller/Providers/IExternalId.cs +++ b/MediaBrowser.Controller/Providers/IExternalId.cs @@ -1,3 +1,4 @@ +using System; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; @@ -33,6 +34,7 @@ namespace MediaBrowser.Controller.Providers /// <summary> /// Gets the URL format string for this id. /// </summary> + [Obsolete("Obsolete in 10.10, to be removed in 10.11")] string? UrlFormatString { get; } /// <summary> diff --git a/MediaBrowser.Controller/Providers/IExternalUrlProvider.cs b/MediaBrowser.Controller/Providers/IExternalUrlProvider.cs new file mode 100644 index 000000000..86a180627 --- /dev/null +++ b/MediaBrowser.Controller/Providers/IExternalUrlProvider.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Providers; + +/// <summary> +/// Interface to include related urls for an item. +/// </summary> +public interface IExternalUrlProvider +{ + /// <summary> + /// Gets the external service name. + /// </summary> + string Name { get; } + + /// <summary> + /// Get the list of external urls. + /// </summary> + /// <param name="item">The item to get external urls for.</param> + /// <returns>The list of external urls.</returns> + IEnumerable<string> GetExternalUrls(BaseItem item); +} diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index eb5069b06..38fc5f2cc 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -99,12 +99,14 @@ namespace MediaBrowser.Controller.Providers /// <param name="metadataProviders">Metadata providers to use.</param> /// <param name="metadataSavers">Metadata savers to use.</param> /// <param name="externalIds">External IDs to use.</param> + /// <param name="externalUrlProviders">The list of external url providers.</param> void AddParts( IEnumerable<IImageProvider> imageProviders, IEnumerable<IMetadataService> metadataServices, IEnumerable<IMetadataProvider> metadataProviders, IEnumerable<IMetadataSaver> metadataSavers, - IEnumerable<IExternalId> externalIds); + IEnumerable<IExternalId> externalIds, + IEnumerable<IExternalUrlProvider> externalUrlProviders); /// <summary> /// Gets the available remote images. @@ -141,6 +143,14 @@ namespace MediaBrowser.Controller.Providers where T : BaseItem; /// <summary> + /// Gets the metadata savers for the provided item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <returns>The metadata savers.</returns> + IEnumerable<IMetadataSaver> GetMetadataSavers(BaseItem item, LibraryOptions libraryOptions); + + /// <summary> /// Gets all metadata plugins. /// </summary> /// <returns>IEnumerable{MetadataPlugin}.</returns> diff --git a/MediaBrowser.Controller/Providers/ItemInfo.cs b/MediaBrowser.Controller/Providers/ItemInfo.cs index 3a97127ea..be3b25aee 100644 --- a/MediaBrowser.Controller/Providers/ItemInfo.cs +++ b/MediaBrowser.Controller/Providers/ItemInfo.cs @@ -11,6 +11,8 @@ namespace MediaBrowser.Controller.Providers public ItemInfo(BaseItem item) { Path = item.Path; + ParentId = item.ParentId; + IndexNumber = item.IndexNumber; ContainingFolderPath = item.ContainingFolderPath; IsInMixedFolder = item.IsInMixedFolder; @@ -27,6 +29,10 @@ namespace MediaBrowser.Controller.Providers public string Path { get; set; } + public Guid ParentId { get; set; } + + public int? IndexNumber { get; set; } + public string ContainingFolderPath { get; set; } public VideoType VideoType { get; set; } diff --git a/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs index a8e2946f1..f00d508bb 100644 --- a/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs +++ b/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs @@ -38,19 +38,26 @@ namespace MediaBrowser.LocalMetadata.Images } var parentPathFiles = directoryService.GetFiles(parentPath); + var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan()).ToString(); - var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan()); + var images = GetImageFilesFromFolder(nameWithoutExtension, parentPathFiles); - return GetFilesFromParentFolder(nameWithoutExtension, parentPathFiles); + var metadataSubDir = directoryService.GetDirectories(parentPath).FirstOrDefault(d => d.Name.Equals("metadata", StringComparison.Ordinal)); + if (metadataSubDir is not null) + { + var files = directoryService.GetFiles(metadataSubDir.FullName); + images.AddRange(GetImageFilesFromFolder(nameWithoutExtension, files)); + } + + return images; } - private List<LocalImageInfo> GetFilesFromParentFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> parentPathFiles) + private List<LocalImageInfo> GetImageFilesFromFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> filePaths) { - var thumbName = string.Concat(filenameWithoutExtension, "-thumb"); - var list = new List<LocalImageInfo>(1); + var thumbName = string.Concat(filenameWithoutExtension, "-thumb"); - foreach (var i in parentPathFiles) + foreach (var i in filePaths) { if (i.IsDirectory) { diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 3b7745b6a..914990558 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -89,15 +89,28 @@ namespace MediaBrowser.MediaEncoding.Attachments string outputPath, CancellationToken cancellationToken) { - using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) + var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)); + if (shouldExtractOneByOne) { - if (!Directory.Exists(outputPath)) + var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index); + foreach (var i in attachmentIndexes) { - await ExtractAllAttachmentsInternal( - _mediaEncoder.GetInputArgument(inputFile, mediaSource), - outputPath, - false, - cancellationToken).ConfigureAwait(false); + var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture)); + await ExtractAttachment(inputFile, mediaSource, i, newName, cancellationToken).ConfigureAwait(false); + } + } + else + { + using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) + { + if (!Directory.Exists(outputPath)) + { + await ExtractAllAttachmentsInternal( + _mediaEncoder.GetInputArgument(inputFile, mediaSource), + outputPath, + false, + cancellationToken).ConfigureAwait(false); + } } } } diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs index 8ebb59c59..5bae4fbd5 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs @@ -86,7 +86,7 @@ public class BdInfoExaminer : IBlurayExaminer if (playlist.StreamClips is not null && playlist.StreamClips.Count > 0) { // Get the files in the playlist - outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.Name).ToArray(); + outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.FileInfo.FullName).ToArray(); } return outputStream; diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index ec39f159e..30bb21dcb 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -103,6 +103,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // sw "alphasrc", "zscale", + "tonemapx", // qsv "scale_qsv", "vpp_qsv", diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 39431a9fc..de1b65482 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -30,10 +30,8 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; -using Microsoft.AspNetCore.Components.Forms; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using static Nikse.SubtitleEdit.Core.Common.IfoParser; namespace MediaBrowser.MediaEncoding.Encoder { @@ -458,9 +456,9 @@ namespace MediaBrowser.MediaEncoding.Encoder extraArgs += " -probesize " + ffmpegProbeSize; } - if (request.MediaSource.RequiredHttpHeaders.TryGetValue("user_agent", out var userAgent)) + if (request.MediaSource.RequiredHttpHeaders.TryGetValue("User-Agent", out var userAgent)) { - extraArgs += " -user_agent " + userAgent; + extraArgs += $" -user_agent \"{userAgent}\""; } if (request.MediaSource.Protocol == MediaProtocol.Rtsp) @@ -621,7 +619,7 @@ namespace MediaBrowser.MediaEncoding.Encoder ImageFormat? targetFormat, CancellationToken cancellationToken) { - var inputArgument = GetInputArgument(inputFile, mediaSource); + var inputArgument = GetInputPathArgument(inputFile, mediaSource); if (!isAudio) { @@ -710,16 +708,22 @@ namespace MediaBrowser.MediaEncoding.Encoder filters.Add("thumbnail=n=" + (useLargerBatchSize ? "50" : "24")); } - // Use SW tonemap on HDR10/HLG video stream only when the zscale filter is available. + // Use SW tonemap on HDR10/HLG video stream only when the zscale or tonemapx filter is available. var enableHdrExtraction = false; - if ((string.Equals(videoStream?.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) + if (string.Equals(videoStream?.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) || string.Equals(videoStream?.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)) - && SupportsFilter("zscale")) { - enableHdrExtraction = true; - - filters.Add("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0:peak=100,zscale=t=bt709:m=bt709,format=yuv420p"); + if (SupportsFilter("tonemapx")) + { + enableHdrExtraction = true; + filters.Add("tonemapx=tonemap=bt2390:desat=0:peak=100:t=bt709:m=bt709:p=bt709:format=yuv420p"); + } + else if (SupportsFilter("zscale")) + { + enableHdrExtraction = true; + filters.Add("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0:peak=100,zscale=t=bt709:m=bt709,format=yuv420p"); + } } var vf = string.Join(',', filters); @@ -809,12 +813,28 @@ namespace MediaBrowser.MediaEncoding.Encoder int? threads, int? qualityScale, ProcessPriorityClass? priority, + bool enableKeyFrameOnlyExtraction, EncodingHelper encodingHelper, CancellationToken cancellationToken) { var options = allowHwAccel ? _configurationManager.GetEncodingOptions() : new EncodingOptions(); threads ??= _threads; + if (allowHwAccel && enableKeyFrameOnlyExtraction) + { + var supportsKeyFrameOnly = (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && options.EnableEnhancedNvdecDecoder) + || (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && OperatingSystem.IsWindows()) + || (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && options.PreferSystemNativeHwDecoder) + || string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) + || string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase); + if (!supportsKeyFrameOnly) + { + // Disable hardware acceleration when the hardware decoder does not support keyframe only mode. + allowHwAccel = false; + options = new EncodingOptions(); + } + } + // A new EncodingOptions instance must be used as to not disable HW acceleration for all of Jellyfin. // Additionally, we must set a few fields without defaults to prevent null pointer exceptions. if (!allowHwAccel) @@ -824,6 +844,22 @@ namespace MediaBrowser.MediaEncoding.Encoder options.EnableTonemapping = false; } + if (imageStream.Width is not null && imageStream.Height is not null && !string.IsNullOrEmpty(imageStream.AspectRatio)) + { + // For hardware trickplay encoders, we need to re-calculate the size because they used fixed scale dimensions + var darParts = imageStream.AspectRatio.Split(':'); + var (wa, ha) = (double.Parse(darParts[0], CultureInfo.InvariantCulture), double.Parse(darParts[1], CultureInfo.InvariantCulture)); + // When dimension / DAR does not equal to 1:1, then the frames are most likely stored stretched. + // Note: this might be incorrect for 3D videos as the SAR stored might be per eye instead of per video, but we really can do little about it. + var shouldResetHeight = Math.Abs((imageStream.Width.Value * ha) - (imageStream.Height.Value * wa)) > .05; + if (shouldResetHeight) + { + // SAR = DAR * Height / Width + // RealHeight = Height / SAR = Height / (DAR * Height / Width) = Width / DAR + imageStream.Height = Convert.ToInt32(imageStream.Width.Value * ha / wa); + } + } + var baseRequest = new BaseEncodingJobOptions { MaxWidth = maxWidth, MaxFramerate = (float)(1.0 / interval.TotalSeconds) }; var jobState = new EncodingJobInfo(TranscodingJobType.Progressive) { @@ -848,6 +884,11 @@ namespace MediaBrowser.MediaEncoding.Encoder inputArg = "-threads " + threads + " " + inputArg; // HW accel args set a different input thread count, only set if disabled } + if (enableKeyFrameOnlyExtraction) + { + inputArg = "-skip_frame nokey " + inputArg; + } + var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, vidEncoder).Trim(); if (string.IsNullOrWhiteSpace(filterParam)) { @@ -871,6 +912,15 @@ namespace MediaBrowser.MediaEncoding.Encoder throw new InvalidOperationException("Empty or invalid input argument."); } + float? encoderQuality = qualityScale; + if (vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) + { + // vaapi's mjpeg encoder uses jpeg quality divided by QP2LAMBDA (118) as input, instead of ffmpeg defined qscale + // ffmpeg qscale is a value from 1-31, with 1 being best quality and 31 being worst + // jpeg quality is a value from 0-100, with 0 being worst quality and 100 being best + encoderQuality = (100 - ((qualityScale - 1) * (100 / 30))) / 118; + } + // Output arguments var targetDirectory = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(targetDirectory); @@ -884,7 +934,7 @@ namespace MediaBrowser.MediaEncoding.Encoder filterParam, outputThreads.GetValueOrDefault(_threads), vidEncoder, - qualityScale.HasValue ? "-qscale:v " + qualityScale.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty, + qualityScale.HasValue ? "-qscale:v " + encoderQuality.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty, "image2", outputPath); @@ -936,7 +986,7 @@ namespace MediaBrowser.MediaEncoding.Encoder var timeoutMs = _configurationManager.Configuration.ImageExtractionTimeoutMs; timeoutMs = timeoutMs <= 0 ? DefaultHdrImageExtractionTimeout : timeoutMs; - while (isResponsive) + while (isResponsive && !cancellationToken.IsCancellationRequested) { try { @@ -950,8 +1000,6 @@ namespace MediaBrowser.MediaEncoding.Encoder // We don't actually expect the process to be finished in one timeout span, just that one image has been generated. } - cancellationToken.ThrowIfCancellationRequested(); - var jpegCount = _fileSystem.GetFilePaths(targetDirectory).Count(); isResponsive = jpegCount > lastCount; @@ -960,7 +1008,12 @@ namespace MediaBrowser.MediaEncoding.Encoder if (!ranToCompletion) { - _logger.LogInformation("Stopping trickplay extraction due to process inactivity."); + if (!isResponsive) + { + _logger.LogInformation("Trickplay process unresponsive."); + } + + _logger.LogInformation("Stopping trickplay extraction."); StopProcess(processWrapper, 1000); } } @@ -1123,19 +1176,21 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <inheritdoc /> public IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path) - { - // Get all playable .m2ts files - var validPlaybackFiles = _blurayExaminer.GetDiscInfo(path).Files; + => _blurayExaminer.GetDiscInfo(path).Files; - // Get all files from the BDMV/STREAMING directory - var directoryFiles = _fileSystem.GetFiles(Path.Join(path, "BDMV", "STREAM")); + /// <inheritdoc /> + public string GetInputPathArgument(EncodingJobInfo state) + => GetInputPathArgument(state.MediaPath, state.MediaSource); - // Only return playable local .m2ts files - return directoryFiles - .Where(f => validPlaybackFiles.Contains(f.Name, StringComparer.OrdinalIgnoreCase)) - .Select(f => f.FullName) - .Order() - .ToList(); + /// <inheritdoc /> + public string GetInputPathArgument(string path, MediaSourceInfo mediaSource) + { + return mediaSource.VideoType switch + { + VideoType.Dvd => GetInputArgument(GetPrimaryPlaylistVobFiles(path, null), mediaSource), + VideoType.BluRay => GetInputArgument(GetPrimaryPlaylistM2tsFiles(path), mediaSource), + _ => GetInputArgument(path, mediaSource) + }; } /// <inheritdoc /> @@ -1158,6 +1213,7 @@ namespace MediaBrowser.MediaEncoding.Encoder } // Generate concat configuration entries for each file and write to file + Directory.CreateDirectory(Path.GetDirectoryName(concatFilePath)); using StreamWriter sw = new StreamWriter(concatFilePath); foreach (var path in files) { diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index a587fa9db..f5185398c 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -280,8 +280,8 @@ namespace MediaBrowser.MediaEncoding.Probing splitFormat[i] = "mpeg"; } - // Handle MPEG-2 container - else if (string.Equals(splitFormat[i], "mpeg", StringComparison.OrdinalIgnoreCase)) + // Handle MPEG-TS container + else if (string.Equals(splitFormat[i], "mpegts", StringComparison.OrdinalIgnoreCase)) { splitFormat[i] = "ts"; } @@ -624,15 +624,19 @@ namespace MediaBrowser.MediaEncoding.Probing { if (string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase)) { - codec = "dvbsub"; + codec = "DVBSUB"; } - else if ((codec ?? string.Empty).Contains("PGS", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(codec, "dvb_teletext", StringComparison.OrdinalIgnoreCase)) { - codec = "PGSSUB"; + codec = "DVBTXT"; } - else if ((codec ?? string.Empty).Contains("DVD", StringComparison.OrdinalIgnoreCase)) + else if (string.Equals(codec, "dvd_subtitle", StringComparison.OrdinalIgnoreCase)) { - codec = "DVDSUB"; + codec = "DVDSUB"; // .sub+.idx + } + else if (string.Equals(codec, "hdmv_pgs_subtitle", StringComparison.OrdinalIgnoreCase)) + { + codec = "PGSSUB"; // .sup } return codec; @@ -779,11 +783,10 @@ namespace MediaBrowser.MediaEncoding.Probing && !string.Equals(streamInfo.FieldOrder, "progressive", StringComparison.OrdinalIgnoreCase); if (isAudio - && (string.Equals(stream.Codec, "bmp", StringComparison.OrdinalIgnoreCase) - || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) - || string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase) - || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase) - || string.Equals(stream.Codec, "webp", StringComparison.OrdinalIgnoreCase))) + || string.Equals(stream.Codec, "bmp", StringComparison.OrdinalIgnoreCase) + || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) + || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase) + || string.Equals(stream.Codec, "webp", StringComparison.OrdinalIgnoreCase)) { stream.Type = MediaStreamType.EmbeddedImage; } @@ -1316,23 +1319,38 @@ namespace MediaBrowser.MediaEncoding.Probing // These support multiple values, but for now we only store the first. var mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Artist Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMARTISTID")); - audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb); + } mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Artist Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ARTISTID")); - audio.SetProviderId(MetadataProvider.MusicBrainzArtist, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzArtist, mb); + } mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMID")); - audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, mb); + } mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Group Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASEGROUPID")); - audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb); + } mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Track Id")) ?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASETRACKID")); - audio.SetProviderId(MetadataProvider.MusicBrainzTrack, mb); + if (!string.IsNullOrEmpty(mb)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzTrack, mb); + } } private string GetMultipleMusicBrainzId(string value) diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 16837d98b..9ecbfa9cf 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -198,10 +198,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles { if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) { - await ExtractAllTextSubtitles(mediaSource, cancellationToken).ConfigureAwait(false); + await ExtractAllExtractableSubtitles(mediaSource, cancellationToken).ConfigureAwait(false); - var outputFormat = GetTextSubtitleFormat(subtitleStream); - var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFormat); + var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream); + var outputFormat = GetExtractableSubtitleFormat(subtitleStream); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension); return new SubtitleInfo() { @@ -215,6 +216,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec) .TrimStart('.'); + // Handle PGS subtitles as raw streams for the client to render + if (MediaStream.IsPgsFormat(currentFormat)) + { + return new SubtitleInfo() + { + Path = subtitleStream.Path, + Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path), + Format = "pgssub", + IsExternal = true + }; + } + // Fallback to ffmpeg conversion if (!_subtitleParser.SupportsFileExtension(currentFormat)) { @@ -428,10 +441,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); } - private string GetTextSubtitleFormat(MediaStream subtitleStream) + private string GetExtractableSubtitleFormat(MediaStream subtitleStream) { if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) - || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)) + || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase) + || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase)) { return subtitleStream.Codec; } @@ -441,21 +455,35 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } + private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream) + { + // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup. + if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase)) + { + return "sup"; + } + else + { + return GetExtractableSubtitleFormat(subtitleStream); + } + } + private bool IsCodecCopyable(string codec) { return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase) || string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase) - || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase); + || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase); } /// <summary> - /// Extracts all text subtitles. + /// Extracts all extractable subtitles (text and pgs). /// </summary> /// <param name="mediaSource">The mediaSource.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - private async Task ExtractAllTextSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) + private async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken) { var locks = new List<IDisposable>(); var extractableStreams = new List<MediaStream>(); @@ -463,11 +491,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles try { var subtitleStreams = mediaSource.MediaStreams - .Where(stream => stream is { IsTextSubtitleStream: true, SupportsExternalStream: true, IsExternal: false }); + .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true, IsExternal: false }); foreach (var subtitleStream in subtitleStreams) { - var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream)); var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false); @@ -483,7 +511,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles if (extractableStreams.Count > 0) { - await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false); + await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) @@ -496,7 +524,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles } } - private async Task ExtractAllTextSubtitlesInternal( + private async Task ExtractAllExtractableSubtitlesInternal( MediaSourceInfo mediaSource, List<MediaStream> subtitleStreams, CancellationToken cancellationToken) @@ -510,7 +538,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles foreach (var subtitleStream in subtitleStreams) { - var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream)); + var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream)); var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt"; var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs index ea5dbf7f7..0b09e57b5 100644 --- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs +++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs @@ -235,15 +235,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable if (delete(job.Path!)) { await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false); - if (job.MediaSource?.VideoType == VideoType.Dvd || job.MediaSource?.VideoType == VideoType.BluRay) - { - var concatFilePath = Path.Join(_serverConfigurationManager.GetTranscodePath(), job.MediaSource.Id + ".concat"); - if (File.Exists(concatFilePath)) - { - _logger.LogInformation("Deleting ffmpeg concat configuration at {Path}", concatFilePath); - File.Delete(concatFilePath); - } - } } if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId)) @@ -419,7 +410,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id); if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) { - var concatPath = Path.Join(_serverConfigurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat"); + var concatPath = Path.Join(_appPaths.CachePath, "concat", state.MediaSource.Id + ".concat"); await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); } else diff --git a/MediaBrowser.Model/Configuration/TrickplayOptions.cs b/MediaBrowser.Model/Configuration/TrickplayOptions.cs index a151d3429..578bb306a 100644 --- a/MediaBrowser.Model/Configuration/TrickplayOptions.cs +++ b/MediaBrowser.Model/Configuration/TrickplayOptions.cs @@ -19,6 +19,12 @@ public class TrickplayOptions public bool EnableHwEncoding { get; set; } = false; /// <summary> + /// Gets or sets a value indicating whether to only extract key frames. + /// Significantly faster, but is not compatible with all decoders and/or video files. + /// </summary> + public bool EnableKeyFrameOnlyExtraction { get; set; } = false; + + /// <summary> /// Gets or sets the behavior used by trickplay provider on library scan/update. /// </summary> public TrickplayScanBehavior ScanBehavior { get; set; } = TrickplayScanBehavior.NonBlocking; diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs index 0d2d7c696..dcb3febbd 100644 --- a/MediaBrowser.Model/Entities/MediaStream.cs +++ b/MediaBrowser.Model/Entities/MediaStream.cs @@ -7,6 +7,7 @@ using System.ComponentModel; using System.Globalization; using System.Linq; using System.Text; +using System.Text.Json.Serialization; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Model.Dlna; @@ -267,13 +268,13 @@ namespace MediaBrowser.Model.Entities attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language)); } - if (!string.IsNullOrEmpty(Codec) && !string.Equals(Codec, "dca", StringComparison.OrdinalIgnoreCase) && !string.Equals(Codec, "dts", StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase)) { - attributes.Add(AudioCodec.GetFriendlyName(Codec)); + attributes.Add(Profile); } - else if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase)) + else if (!string.IsNullOrEmpty(Codec)) { - attributes.Add(Profile); + attributes.Add(AudioCodec.GetFriendlyName(Codec)); } if (!string.IsNullOrEmpty(ChannelLayout)) @@ -585,6 +586,33 @@ namespace MediaBrowser.Model.Entities } } + [JsonIgnore] + public bool IsPgsSubtitleStream + { + get + { + if (Type != MediaStreamType.Subtitle) + { + return false; + } + + if (string.IsNullOrEmpty(Codec) && !IsExternal) + { + return false; + } + + return IsPgsFormat(Codec); + } + } + + /// <summary> + /// Gets a value indicating whether this is a subtitle steam that is extractable by ffmpeg. + /// All text-based and pgs subtitles can be extracted. + /// </summary> + /// <value><c>true</c> if this is a extractable subtitle steam otherwise, <c>false</c>.</value> + [JsonIgnore] + public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream; + /// <summary> /// Gets or sets a value indicating whether [supports external stream]. /// </summary> @@ -656,14 +684,22 @@ namespace MediaBrowser.Model.Entities { string codec = format ?? string.Empty; - // sub = external .sub file + // microdvd and dvdsub/vobsub share the ".sub" file extension, but it's text-based. + + return codec.Contains("microdvd", StringComparison.OrdinalIgnoreCase) + || (!codec.Contains("pgs", StringComparison.OrdinalIgnoreCase) + && !codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase) + && !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase) + && !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase) + && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase)); + } + + public static bool IsPgsFormat(string format) + { + string codec = format ?? string.Empty; - return !codec.Contains("pgs", StringComparison.OrdinalIgnoreCase) - && !codec.Contains("dvd", StringComparison.OrdinalIgnoreCase) - && !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase) - && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase) - && !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase) - && !string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase); + return codec.Contains("pgs", StringComparison.OrdinalIgnoreCase) + || string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase); } public bool SupportsSubtitleConversionTo(string toCodec) diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index cf453d62c..1c73091f0 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -111,31 +111,32 @@ namespace MediaBrowser.Model.Entities /// Sets a provider id. /// </summary> /// <param name="instance">The instance.</param> - /// <param name="name">The name.</param> + /// <param name="name">The name, this should not contain a '=' character.</param> /// <param name="value">The value.</param> - public static void SetProviderId(this IHasProviderIds instance, string name, string? value) + /// <remarks>Due to how deserialization from the database works the name can not contain '='.</remarks> + public static void SetProviderId(this IHasProviderIds instance, string name, string value) { ArgumentNullException.ThrowIfNull(instance); + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(value); + + // When name contains a '=' it can't be deserialized from the database + if (name.Contains('=', StringComparison.Ordinal)) + { + throw new ArgumentException("Provider id name cannot contain '='", nameof(name)); + } + + // Ensure it exists + instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - // If it's null remove the key from the dictionary - if (string.IsNullOrEmpty(value)) + // Match on internal MetadataProvider enum string values before adding arbitrary providers + if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue)) { - instance.ProviderIds?.Remove(name); + instance.ProviderIds[enumValue] = value; } else { - // Ensure it exists - instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - // Match on internal MetadataProvider enum string values before adding arbitrary providers - if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue)) - { - instance.ProviderIds[enumValue] = value; - } - else - { - instance.ProviderIds[name] = value; - } + instance.ProviderIds[name] = value; } } @@ -149,5 +150,30 @@ namespace MediaBrowser.Model.Entities { instance.SetProviderId(provider.ToString(), value); } + + /// <summary> + /// Removes a provider id. + /// </summary> + /// <param name="instance">The instance.</param> + /// <param name="name">The name.</param> + public static void RemoveProviderId(this IHasProviderIds instance, string name) + { + ArgumentNullException.ThrowIfNull(instance); + ArgumentException.ThrowIfNullOrEmpty(name); + + instance.ProviderIds?.Remove(name); + } + + /// <summary> + /// Removes a provider id. + /// </summary> + /// <param name="instance">The instance.</param> + /// <param name="provider">The provider.</param> + public static void RemoveProviderId(this IHasProviderIds instance, MetadataProvider provider) + { + ArgumentNullException.ThrowIfNull(instance); + + instance.ProviderIds?.Remove(provider.ToString()); + } } } diff --git a/MediaBrowser.Model/Entities/VirtualFolderInfo.cs b/MediaBrowser.Model/Entities/VirtualFolderInfo.cs index 89bb72c3c..ea3df3726 100644 --- a/MediaBrowser.Model/Entities/VirtualFolderInfo.cs +++ b/MediaBrowser.Model/Entities/VirtualFolderInfo.cs @@ -2,8 +2,6 @@ #pragma warning disable CS1591 using System; -using System.Text.Json.Serialization; -using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Model.Configuration; namespace MediaBrowser.Model.Entities diff --git a/MediaBrowser.Model/Providers/ExternalIdInfo.cs b/MediaBrowser.Model/Providers/ExternalIdInfo.cs index d026d574f..1f5163aa8 100644 --- a/MediaBrowser.Model/Providers/ExternalIdInfo.cs +++ b/MediaBrowser.Model/Providers/ExternalIdInfo.cs @@ -1,3 +1,5 @@ +using System; + namespace MediaBrowser.Model.Providers { /// <summary> @@ -17,7 +19,9 @@ namespace MediaBrowser.Model.Providers Name = name; Key = key; Type = type; +#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11 UrlFormatString = urlFormatString; +#pragma warning restore CS0618 // Type or member is obsolete } /// <summary> @@ -46,6 +50,7 @@ namespace MediaBrowser.Model.Providers /// <summary> /// Gets or sets the URL format string. /// </summary> + [Obsolete("Obsolete in 10.10, to be removed in 10.11")] public string? UrlFormatString { get; set; } } } diff --git a/MediaBrowser.Model/Search/SearchHint.cs b/MediaBrowser.Model/Search/SearchHint.cs index fd911dbed..2e2979fcf 100644 --- a/MediaBrowser.Model/Search/SearchHint.cs +++ b/MediaBrowser.Model/Search/SearchHint.cs @@ -43,7 +43,7 @@ namespace MediaBrowser.Model.Search /// Gets or sets the matched term. /// </summary> /// <value>The matched term.</value> - public string MatchedTerm { get; set; } + public string? MatchedTerm { get; set; } /// <summary> /// Gets or sets the index number. diff --git a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs index f8a8c727e..1d8767dc1 100644 --- a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs +++ b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs @@ -13,7 +13,6 @@ namespace MediaBrowser.Model.Tasks public const string TriggerDaily = "DailyTrigger"; public const string TriggerWeekly = "WeeklyTrigger"; public const string TriggerInterval = "IntervalTrigger"; - public const string TriggerSystemEvent = "SystemEventTrigger"; public const string TriggerStartup = "StartupTrigger"; /// <summary> diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index d82716831..9a676cb2e 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -14,6 +14,7 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; @@ -100,8 +101,8 @@ namespace MediaBrowser.Providers.Manager { saveLocally = false; - // If season is virtual under a physical series, save locally if using compatible convention - if (item is Season season && _config.Configuration.ImageSavingConvention == ImageSavingConvention.Compatible) + // If season is virtual under a physical series, save locally + if (item is Season season) { var series = season.Series; @@ -126,7 +127,11 @@ namespace MediaBrowser.Providers.Manager var paths = GetSavePaths(item, type, imageIndex, mimeType, saveLocally); - var retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false); + string[] retryPaths = []; + if (saveLocally) + { + retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false); + } // If there are more than one output paths, the stream will need to be seekable if (paths.Length > 1 && !source.CanSeek) @@ -183,6 +188,29 @@ namespace MediaBrowser.Providers.Manager try { _fileSystem.DeleteFile(currentPath); + + // Remove local episode metadata directory if it exists and is empty + var directory = Path.GetDirectoryName(currentPath); + if (item is Episode && directory.Equals("metadata", StringComparison.Ordinal)) + { + var parentDirectoryPath = Directory.GetParent(currentPath).FullName; + if (_fileSystem.DirectoryExists(parentDirectoryPath) && !_fileSystem.GetFiles(parentDirectoryPath).Any()) + { + try + { + _logger.LogInformation("Deleting empty local metadata folder {Folder}", parentDirectoryPath); + Directory.Delete(parentDirectoryPath); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath); + } + } + } } catch (FileNotFoundException) { @@ -374,6 +402,47 @@ namespace MediaBrowser.Providers.Manager throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Unable to determine image file extension from mime type {0}", mimeType)); } + if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)) + { + extension = ".jpg"; + } + + extension = extension.ToLowerInvariant(); + + if (type == ImageType.Primary && saveLocally) + { + if (season is not null && season.IndexNumber.HasValue) + { + var seriesFolder = season.SeriesPath; + + var seasonMarker = season.IndexNumber.Value == 0 + ? "-specials" + : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); + + var imageFilename = "season" + seasonMarker + "-poster" + extension; + + return Path.Combine(seriesFolder, imageFilename); + } + } + + if (type == ImageType.Backdrop && saveLocally) + { + if (season is not null + && season.IndexNumber.HasValue + && (imageIndex is null || imageIndex == 0)) + { + var seriesFolder = season.SeriesPath; + + var seasonMarker = season.IndexNumber.Value == 0 + ? "-specials" + : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture); + + var imageFilename = "season" + seasonMarker + "-fanart" + extension; + + return Path.Combine(seriesFolder, imageFilename); + } + } + if (type == ImageType.Thumb && saveLocally) { if (season is not null && season.IndexNumber.HasValue) @@ -447,20 +516,12 @@ namespace MediaBrowser.Providers.Manager break; } - if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)) - { - extension = ".jpg"; - } - - extension = extension.ToLowerInvariant(); - string path = null; - if (saveLocally) { if (type == ImageType.Primary && item is Episode) { - path = Path.Combine(Path.GetDirectoryName(item.Path), "metadata", filename + extension); + path = Path.Combine(Path.GetDirectoryName(item.Path), filename + "-thumb" + extension); } else if (item.IsInMixedFolder) { diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs index 1a5dbd7a5..1bb7ffcce 100644 --- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs +++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; using MediaBrowser.Controller.Providers; @@ -96,7 +97,7 @@ namespace MediaBrowser.Providers.Manager public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, ImageRefreshOptions refreshOptions) { var hasChanges = false; - IDirectoryService directoryService = refreshOptions?.DirectoryService; + var directoryService = refreshOptions?.DirectoryService; if (item is not Photo) { @@ -158,7 +159,7 @@ namespace MediaBrowser.Providers.Manager } } - // only delete existing multi-images if new ones were added + // Only delete existing multi-images if new ones were added if (oldBackdropImages.Length > 0 && oldBackdropImages.Length < item.GetImages(ImageType.Backdrop).Count()) { PruneImages(item, oldBackdropImages); @@ -359,10 +360,8 @@ namespace MediaBrowser.Providers.Manager private void PruneImages(BaseItem item, IReadOnlyList<ItemImageInfo> images) { - for (var i = 0; i < images.Count; i++) + foreach (var image in images) { - var image = images[i]; - if (image.IsLocalFile) { try @@ -371,7 +370,7 @@ namespace MediaBrowser.Providers.Manager } catch (FileNotFoundException) { - // nothing to do, already gone + // Nothing to do, already gone } catch (UnauthorizedAccessException ex) { @@ -381,6 +380,16 @@ namespace MediaBrowser.Providers.Manager } item.RemoveImages(images); + + // Cleanup old metadata directory for episodes if empty + if (item is Episode) + { + var oldLocalMetadataDirectory = Path.Combine(item.ContainingFolderPath, "metadata"); + if (_fileSystem.DirectoryExists(oldLocalMetadataDirectory) && !_fileSystem.GetFiles(oldLocalMetadataDirectory).Any()) + { + Directory.Delete(oldLocalMetadataDirectory); + } + } } /// <summary> @@ -413,12 +422,10 @@ namespace MediaBrowser.Providers.Manager { var changed = item.ValidateImages(); var foundImageTypes = new List<ImageType>(); - for (var i = 0; i < _singularImages.Length; i++) { var type = _singularImages[i]; var image = GetFirstLocalImageInfoByType(images, type); - if (image is not null) { var currentImage = item.GetImageInfo(type, 0); diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index d7990e2a7..8af4ed2a8 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; @@ -93,10 +92,6 @@ namespace MediaBrowser.Providers.Manager } } - var localImagesFailed = false; - - var allImageProviders = ProviderManager.GetImageProviders(item, refreshOptions).ToList(); - if (refreshOptions.RemoveOldMetadata && refreshOptions.ReplaceAllImages) { if (ImageProvider.RemoveImages(item)) @@ -105,24 +100,35 @@ namespace MediaBrowser.Providers.Manager } } - // Start by validating images - try + var localImagesFailed = false; + var allImageProviders = ProviderManager.GetImageProviders(item, refreshOptions).ToList(); + + // Only validate already registered images if we are replacing and saving locally + if (item.IsSaveLocalMetadataEnabled() && refreshOptions.ReplaceAllImages) { - // Always validate images and check for new locally stored ones. - if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions)) - { - updateType |= ItemUpdateType.ImageUpdate; - } + item.ValidateImages(); } - catch (Exception ex) + else { - localImagesFailed = true; - Logger.LogError(ex, "Error validating images for {Item}", item.Path ?? item.Name ?? "Unknown name"); + // Run full image validation and register new local images + try + { + if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions)) + { + updateType |= ItemUpdateType.ImageUpdate; + } + } + catch (Exception ex) + { + localImagesFailed = true; + Logger.LogError(ex, "Error validating images for {Item}", item.Path ?? item.Name ?? "Unknown name"); + } } var metadataResult = new MetadataResult<TItemType> { - Item = itemOfType + Item = itemOfType, + People = LibraryManager.GetPeople(item) }; bool hasRefreshedMetadata = true; @@ -154,7 +160,8 @@ namespace MediaBrowser.Providers.Manager id.IsAutomated = refreshOptions.IsAutomated; - var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, cancellationToken).ConfigureAwait(false); + var hasMetadataSavers = ProviderManager.GetMetadataSavers(item, libraryOptions).Any(); + var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, hasMetadataSavers, cancellationToken).ConfigureAwait(false); updateType |= result.UpdateType; if (result.Failures > 0) @@ -165,7 +172,7 @@ namespace MediaBrowser.Providers.Manager } // Next run remote image providers, but only if local image providers didn't throw an exception - if (!localImagesFailed && refreshOptions.ImageRefreshMode != MetadataRefreshMode.ValidationOnly) + if (!localImagesFailed && refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly) { var providers = GetNonLocalImageProviders(item, allImageProviders, refreshOptions).ToList(); @@ -243,7 +250,7 @@ namespace MediaBrowser.Providers.Manager protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken) { - if (result.Item.SupportsPeople && result.People is not null) + if (result.Item.SupportsPeople) { var baseItem = result.Item; @@ -399,7 +406,8 @@ namespace MediaBrowser.Providers.Manager foreach (var child in children) { - if (!child.IsFolder) + // Exclude any folders and virtual items since they are only placeholders + if (!child.IsFolder && !child.IsVirtualItem) { var childDateCreated = child.DateCreated; if (childDateCreated > dateLastMediaAdded) @@ -638,6 +646,7 @@ namespace MediaBrowser.Providers.Manager MetadataRefreshOptions options, ICollection<IMetadataProvider> providers, ItemImageProvider imageService, + bool isSavingMetadata, CancellationToken cancellationToken) { var refreshResult = new RefreshResult @@ -655,102 +664,96 @@ namespace MediaBrowser.Providers.Manager await RunCustomProvider(provider, item, logName, options, refreshResult, cancellationToken).ConfigureAwait(false); } + if (item.IsLocked) + { + return refreshResult; + } + var temp = new MetadataResult<TItemType> { Item = CreateNew() }; temp.Item.Path = item.Path; + temp.Item.Id = item.Id; + temp.Item.PreferredMetadataCountryCode = item.PreferredMetadataCountryCode; + temp.Item.PreferredMetadataLanguage = item.PreferredMetadataLanguage; - // If replacing all metadata, run internet providers first - if (options.ReplaceAllMetadata) - { - var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken) - .ConfigureAwait(false); - - refreshResult.UpdateType |= remoteResult.UpdateType; - refreshResult.ErrorMessage = remoteResult.ErrorMessage; - refreshResult.Failures += remoteResult.Failures; - } - - var hasLocalMetadata = false; var foundImageTypes = new List<ImageType>(); - foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>()) + // Do not execute local providers if we are identifying or replacing with local metadata saving enabled + if (options.SearchResult is null && !(isSavingMetadata && options.ReplaceAllMetadata)) { - var providerName = provider.GetType().Name; - Logger.LogDebug("Running {Provider} for {Item}", providerName, logName); - - var itemInfo = new ItemInfo(item); - - try + foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>()) { - var localItem = await provider.GetMetadata(itemInfo, options.DirectoryService, cancellationToken).ConfigureAwait(false); + var providerName = provider.GetType().Name; + Logger.LogDebug("Running {Provider} for {Item}", providerName, logName); + + var itemInfo = new ItemInfo(item); - if (localItem.HasMetadata) + try { - foreach (var remoteImage in localItem.RemoteImages) + var localItem = await provider.GetMetadata(itemInfo, options.DirectoryService, cancellationToken).ConfigureAwait(false); + + if (localItem.HasMetadata) { - try + foreach (var remoteImage in localItem.RemoteImages) { - if (item.ImageInfos.Any(x => x.Type == remoteImage.Type) - && !options.IsReplacingImage(remoteImage.Type)) + try { - continue; - } + if (item.ImageInfos.Any(x => x.Type == remoteImage.Type) + && !options.IsReplacingImage(remoteImage.Type)) + { + continue; + } - await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false); - refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; + await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false); + refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; - // remember imagetype that has just been downloaded - foundImageTypes.Add(remoteImage.Type); + // remember imagetype that has just been downloaded + foundImageTypes.Add(remoteImage.Type); + } + catch (HttpRequestException ex) + { + Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.Type), remoteImage.Url); + } } - catch (HttpRequestException ex) + + if (foundImageTypes.Count > 0) { - Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.Type), remoteImage.Url); + imageService.UpdateReplaceImages(options, foundImageTypes); } - } - if (foundImageTypes.Count > 0) - { - imageService.UpdateReplaceImages(options, foundImageTypes); - } - - if (imageService.MergeImages(item, localItem.Images, options)) - { - refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; - } + if (imageService.MergeImages(item, localItem.Images, options)) + { + refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; + } - MergeData(localItem, temp, Array.Empty<MetadataField>(), options.ReplaceAllMetadata, true); - refreshResult.UpdateType |= ItemUpdateType.MetadataImport; + MergeData(localItem, temp, Array.Empty<MetadataField>(), false, true); + refreshResult.UpdateType |= ItemUpdateType.MetadataImport; - // Only one local provider allowed per item - if (item.IsLocked || localItem.Item.IsLocked || IsFullLocalMetadata(localItem.Item)) - { - hasLocalMetadata = true; + break; } - break; + Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName); } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + Logger.LogError(ex, "Error in {Provider}", provider.Name); - Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - Logger.LogError(ex, "Error in {Provider}", provider.Name); - - // If a local provider fails, consider that a failure - refreshResult.ErrorMessage = ex.Message; + // If a local provider fails, consider that a failure + refreshResult.ErrorMessage = ex.Message; + } } } - // Local metadata is king - if any is found don't run remote providers - if (!options.ReplaceAllMetadata && (!hasLocalMetadata || options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || !item.StopRefreshIfLocalMetadataFound)) + var isLocalLocked = temp.Item.IsLocked; + if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly)) { - var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken) + var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken) .ConfigureAwait(false); refreshResult.UpdateType |= remoteResult.UpdateType; @@ -762,19 +765,20 @@ namespace MediaBrowser.Providers.Manager { if (refreshResult.UpdateType > ItemUpdateType.None) { - if (hasLocalMetadata) + if (!options.RemoveOldMetadata) + { + // Add existing metadata to provider result if it does not exist there + MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false); + } + + if (isLocalLocked) { MergeData(temp, metadata, item.LockedFields, true, true); } else { - if (!options.RemoveOldMetadata) - { - MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false); - } - - // Will always replace all metadata when Scan for new and updated files is used. Else, follow the options. - MergeData(temp, metadata, item.LockedFields, options.MetadataRefreshMode == MetadataRefreshMode.Default || options.ReplaceAllMetadata, false); + var shouldReplace = options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly || options.ReplaceAllMetadata; + MergeData(temp, metadata, item.LockedFields, shouldReplace, true); } } } @@ -787,16 +791,6 @@ namespace MediaBrowser.Providers.Manager return refreshResult; } - protected virtual bool IsFullLocalMetadata(TItemType item) - { - if (string.IsNullOrWhiteSpace(item.Name)) - { - return false; - } - - return true; - } - private async Task RunCustomProvider(ICustomMetadataProvider<TItemType> provider, TItemType item, string logName, MetadataRefreshOptions options, RefreshResult refreshResult, CancellationToken cancellationToken) { Logger.LogDebug("Running {Provider} for {Item}", provider.GetType().Name, logName); @@ -821,23 +815,20 @@ namespace MediaBrowser.Providers.Manager return new TItemType(); } - private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken) + private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, bool replaceData, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken) { var refreshResult = new RefreshResult(); - var tmpDataMerged = false; + if (id is not null) + { + MergeNewData(temp.Item, id); + } foreach (var provider in providers) { var providerName = provider.GetType().Name; Logger.LogDebug("Running {Provider} for {Item}", providerName, logName); - if (id is not null && !tmpDataMerged) - { - MergeNewData(temp.Item, id); - tmpDataMerged = true; - } - try { var result = await provider.GetMetadata(id, cancellationToken).ConfigureAwait(false); @@ -846,7 +837,7 @@ namespace MediaBrowser.Providers.Manager { result.Provider = provider.Name; - MergeData(result, temp, Array.Empty<MetadataField>(), false, false); + MergeData(result, temp, Array.Empty<MetadataField>(), replaceData, false); MergeNewData(temp.Item, id); refreshResult.UpdateType |= ItemUpdateType.MetadataDownload; @@ -949,11 +940,7 @@ namespace MediaBrowser.Providers.Manager if (replaceData || string.IsNullOrEmpty(target.OriginalTitle)) { - // Safeguard against incoming data having an empty name - if (!string.IsNullOrWhiteSpace(source.OriginalTitle)) - { - target.OriginalTitle = source.OriginalTitle; - } + target.OriginalTitle = source.OriginalTitle; } if (replaceData || !target.CommunityRating.HasValue) @@ -1016,7 +1003,7 @@ namespace MediaBrowser.Providers.Manager { targetResult.People = sourceResult.People; } - else if (targetResult.People is not null && sourceResult.People is not null) + else if (sourceResult.People is not null && sourceResult.People.Count > 0) { MergePeople(sourceResult.People, targetResult.People); } @@ -1049,6 +1036,10 @@ namespace MediaBrowser.Providers.Manager { target.Studios = source.Studios; } + else + { + target.Studios = target.Studios.Concat(source.Studios).Distinct().ToArray(); + } } if (!lockedFields.Contains(MetadataField.Tags)) @@ -1057,6 +1048,10 @@ namespace MediaBrowser.Providers.Manager { target.Tags = source.Tags; } + else + { + target.Tags = target.Tags.Concat(source.Tags).Distinct().ToArray(); + } } if (!lockedFields.Contains(MetadataField.ProductionLocations)) @@ -1065,6 +1060,10 @@ namespace MediaBrowser.Providers.Manager { target.ProductionLocations = source.ProductionLocations; } + else + { + target.ProductionLocations = target.ProductionLocations.Concat(source.ProductionLocations).Distinct().ToArray(); + } } foreach (var id in source.ProviderIds) @@ -1082,17 +1081,28 @@ namespace MediaBrowser.Providers.Manager } } + if (replaceData || !target.CriticRating.HasValue) + { + target.CriticRating = source.CriticRating; + } + + if (replaceData || target.RemoteTrailers.Count == 0) + { + target.RemoteTrailers = source.RemoteTrailers; + } + else + { + target.RemoteTrailers = target.RemoteTrailers.Concat(source.RemoteTrailers).DistinctBy(t => t.Url).ToArray(); + } + MergeAlbumArtist(source, target, replaceData); - MergeCriticRating(source, target, replaceData); - MergeTrailers(source, target, replaceData); MergeVideoInfo(source, target, replaceData); MergeDisplayOrder(source, target, replaceData); if (replaceData || string.IsNullOrEmpty(target.ForcedSortName)) { var forcedSortName = source.ForcedSortName; - - if (!string.IsNullOrWhiteSpace(forcedSortName)) + if (!string.IsNullOrEmpty(forcedSortName)) { target.ForcedSortName = forcedSortName; } @@ -1100,22 +1110,44 @@ namespace MediaBrowser.Providers.Manager if (mergeMetadataSettings) { - target.LockedFields = source.LockedFields; - target.IsLocked = source.IsLocked; + if (replaceData || !target.IsLocked) + { + target.IsLocked = target.IsLocked || source.IsLocked; + } + + if (target.LockedFields.Length == 0) + { + target.LockedFields = source.LockedFields; + } + else + { + target.LockedFields = target.LockedFields.Concat(source.LockedFields).Distinct().ToArray(); + } - // Grab the value if it's there, but if not then don't overwrite with the default if (source.DateCreated != default) { target.DateCreated = source.DateCreated; } - target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode; - target.PreferredMetadataLanguage = source.PreferredMetadataLanguage; + if (replaceData || string.IsNullOrEmpty(target.PreferredMetadataCountryCode)) + { + target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode; + } + + if (replaceData || string.IsNullOrEmpty(target.PreferredMetadataLanguage)) + { + target.PreferredMetadataLanguage = source.PreferredMetadataLanguage; + } } } private static void MergePeople(List<PersonInfo> source, List<PersonInfo> target) { + if (target is null) + { + target = new List<PersonInfo>(); + } + foreach (var person in target) { var normalizedName = person.Name.RemoveDiacritics(); @@ -1144,7 +1176,6 @@ namespace MediaBrowser.Providers.Manager if (replaceData || string.IsNullOrEmpty(targetHasDisplayOrder.DisplayOrder)) { var displayOrder = sourceHasDisplayOrder.DisplayOrder; - if (!string.IsNullOrWhiteSpace(displayOrder)) { targetHasDisplayOrder.DisplayOrder = displayOrder; @@ -1162,22 +1193,10 @@ namespace MediaBrowser.Providers.Manager { targetHasAlbumArtist.AlbumArtists = sourceHasAlbumArtist.AlbumArtists; } - } - } - - private static void MergeCriticRating(BaseItem source, BaseItem target, bool replaceData) - { - if (replaceData || !target.CriticRating.HasValue) - { - target.CriticRating = source.CriticRating; - } - } - - private static void MergeTrailers(BaseItem source, BaseItem target, bool replaceData) - { - if (replaceData || target.RemoteTrailers.Count == 0) - { - target.RemoteTrailers = source.RemoteTrailers; + else if (sourceHasAlbumArtist.AlbumArtists.Count > 0) + { + targetHasAlbumArtist.AlbumArtists = targetHasAlbumArtist.AlbumArtists.Concat(sourceHasAlbumArtist.AlbumArtists).Distinct().ToArray(); + } } } @@ -1185,7 +1204,7 @@ namespace MediaBrowser.Providers.Manager { if (source is Video sourceCast && target is Video targetCast) { - if (replaceData || targetCast.Video3DFormat is null) + if (replaceData || !targetCast.Video3DFormat.HasValue) { targetCast.Video3DFormat = sourceCast.Video3DFormat; } diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 275f4028d..60d89a51b 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -69,11 +69,12 @@ namespace MediaBrowser.Providers.Manager o.PoolInitialFill = 1; }); - private IImageProvider[] _imageProviders = Array.Empty<IImageProvider>(); - private IMetadataService[] _metadataServices = Array.Empty<IMetadataService>(); - private IMetadataProvider[] _metadataProviders = Array.Empty<IMetadataProvider>(); - private IMetadataSaver[] _savers = Array.Empty<IMetadataSaver>(); - private IExternalId[] _externalIds = Array.Empty<IExternalId>(); + private IImageProvider[] _imageProviders = []; + private IMetadataService[] _metadataServices = []; + private IMetadataProvider[] _metadataProviders = []; + private IMetadataSaver[] _savers = []; + private IExternalId[] _externalIds = []; + private IExternalUrlProvider[] _externalUrlProviders = []; private bool _isProcessingRefreshQueue; private bool _disposed; @@ -132,12 +133,14 @@ namespace MediaBrowser.Providers.Manager IEnumerable<IMetadataService> metadataServices, IEnumerable<IMetadataProvider> metadataProviders, IEnumerable<IMetadataSaver> metadataSavers, - IEnumerable<IExternalId> externalIds) + IEnumerable<IExternalId> externalIds, + IEnumerable<IExternalUrlProvider> externalUrlProviders) { _imageProviders = imageProviders.ToArray(); _metadataServices = metadataServices.OrderBy(i => i.Order).ToArray(); _metadataProviders = metadataProviders.ToArray(); _externalIds = externalIds.OrderBy(i => i.ProviderName).ToArray(); + _externalUrlProviders = externalUrlProviders.OrderBy(i => i.Name).ToArray(); _savers = metadataSavers.ToArray(); } @@ -418,6 +421,12 @@ namespace MediaBrowser.Providers.Manager return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false); } + /// <inheritdoc /> + public IEnumerable<IMetadataSaver> GetMetadataSavers(BaseItem item, LibraryOptions libraryOptions) + { + return _savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, ItemUpdateType.MetadataEdit, false)); + } + private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata) where T : BaseItem { @@ -871,31 +880,35 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public IEnumerable<ExternalUrl> GetExternalUrls(BaseItem item) { - return GetExternalIds(item) +#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11 + var legacyExternalIdUrls = GetExternalIds(item) .Select(i => - { - if (string.IsNullOrEmpty(i.UrlFormatString)) { - return null; - } + var urlFormatString = i.UrlFormatString; + if (string.IsNullOrEmpty(urlFormatString) + || !item.TryGetProviderId(i.Key, out var providerId)) + { + return null; + } - var value = item.GetProviderId(i.Key); + return new ExternalUrl + { + Name = i.ProviderName, + Url = string.Format( + CultureInfo.InvariantCulture, + urlFormatString, + providerId) + }; + }) + .OfType<ExternalUrl>(); +#pragma warning restore CS0618 // Type or member is obsolete - if (string.IsNullOrEmpty(value)) - { - return null; - } + var externalUrls = _externalUrlProviders + .SelectMany(p => p + .GetExternalUrls(item) + .Select(externalUrl => new ExternalUrl { Name = p.Name, Url = externalUrl })); - return new ExternalUrl - { - Name = i.ProviderName, - Url = string.Format( - CultureInfo.InvariantCulture, - i.UrlFormatString, - value) - }; - }).Where(i => i is not null) - .Concat(item.GetRelatedUrls())!; // We just filtered out all the nulls + return legacyExternalIdUrls.Concat(externalUrls).OrderBy(u => u.Name); } /// <inheritdoc/> @@ -906,7 +919,9 @@ namespace MediaBrowser.Providers.Manager name: i.ProviderName, key: i.Key, type: i.Type, +#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11 urlFormatString: i.UrlFormatString)); +#pragma warning restore CS0618 // Type or member is obsolete } /// <inheritdoc/> diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index 9f37d8b54..0083d4f75 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -15,6 +17,7 @@ using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.MediaInfo; +using Microsoft.Extensions.Logging; using TagLib; namespace MediaBrowser.Providers.MediaInfo @@ -27,6 +30,7 @@ namespace MediaBrowser.Providers.MediaInfo private readonly IMediaEncoder _mediaEncoder; private readonly IItemRepository _itemRepo; private readonly ILibraryManager _libraryManager; + private readonly ILogger<AudioFileProber> _logger; private readonly IMediaSourceManager _mediaSourceManager; private readonly LyricResolver _lyricResolver; private readonly ILyricManager _lyricManager; @@ -34,6 +38,7 @@ namespace MediaBrowser.Providers.MediaInfo /// <summary> /// Initializes a new instance of the <see cref="AudioFileProber"/> class. /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param> /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param> @@ -41,6 +46,7 @@ namespace MediaBrowser.Providers.MediaInfo /// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param> /// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param> public AudioFileProber( + ILogger<AudioFileProber> logger, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IItemRepository itemRepo, @@ -51,6 +57,7 @@ namespace MediaBrowser.Providers.MediaInfo _mediaEncoder = mediaEncoder; _itemRepo = itemRepo; _libraryManager = libraryManager; + _logger = logger; _mediaSourceManager = mediaSourceManager; _lyricResolver = lyricResolver; _lyricManager = lyricManager; @@ -130,6 +137,10 @@ namespace MediaBrowser.Providers.MediaInfo if (!audio.IsLocked) { await FetchDataFromTags(audio, mediaInfo, options, tryExtractEmbeddedLyrics).ConfigureAwait(false); + if (tryExtractEmbeddedLyrics) + { + AddExternalLyrics(audio, mediaStreams, options); + } } audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric); @@ -146,190 +157,215 @@ namespace MediaBrowser.Providers.MediaInfo /// <param name="tryExtractEmbeddedLyrics">Whether to extract embedded lyrics to lrc file. </param> private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions options, bool tryExtractEmbeddedLyrics) { - using var file = TagLib.File.Create(audio.Path); - var tagTypes = file.TagTypesOnDisk; Tag? tags = null; - - if (tagTypes.HasFlag(TagTypes.Id3v2)) - { - tags = file.GetTag(TagTypes.Id3v2); - } - else if (tagTypes.HasFlag(TagTypes.Ape)) - { - tags = file.GetTag(TagTypes.Ape); - } - else if (tagTypes.HasFlag(TagTypes.FlacMetadata)) - { - tags = file.GetTag(TagTypes.FlacMetadata); - } - else if (tagTypes.HasFlag(TagTypes.Apple)) - { - tags = file.GetTag(TagTypes.Apple); - } - else if (tagTypes.HasFlag(TagTypes.Xiph)) - { - tags = file.GetTag(TagTypes.Xiph); - } - else if (tagTypes.HasFlag(TagTypes.AudibleMetadata)) + try { - tags = file.GetTag(TagTypes.AudibleMetadata); + using var file = TagLib.File.Create(audio.Path); + var tagTypes = file.TagTypesOnDisk; + + if (tagTypes.HasFlag(TagTypes.Id3v2)) + { + tags = file.GetTag(TagTypes.Id3v2); + } + else if (tagTypes.HasFlag(TagTypes.Ape)) + { + tags = file.GetTag(TagTypes.Ape); + } + else if (tagTypes.HasFlag(TagTypes.FlacMetadata)) + { + tags = file.GetTag(TagTypes.FlacMetadata); + } + else if (tagTypes.HasFlag(TagTypes.Apple)) + { + tags = file.GetTag(TagTypes.Apple); + } + else if (tagTypes.HasFlag(TagTypes.Xiph)) + { + tags = file.GetTag(TagTypes.Xiph); + } + else if (tagTypes.HasFlag(TagTypes.AudibleMetadata)) + { + tags = file.GetTag(TagTypes.AudibleMetadata); + } + else if (tagTypes.HasFlag(TagTypes.Id3v1)) + { + tags = file.GetTag(TagTypes.Id3v1); + } } - else if (tagTypes.HasFlag(TagTypes.Id3v1)) + catch (Exception e) { - tags = file.GetTag(TagTypes.Id3v1); + _logger.LogWarning(e, "TagLib-Sharp does not support this audio"); } - if (tags is not null) + tags ??= new TagLib.Id3v2.Tag(); + tags.AlbumArtists ??= mediaInfo.AlbumArtists; + tags.Album ??= mediaInfo.Album; + tags.Title ??= mediaInfo.Name; + tags.Year = tags.Year == 0U ? Convert.ToUInt32(mediaInfo.ProductionYear, CultureInfo.InvariantCulture) : tags.Year; + tags.Performers ??= mediaInfo.Artists; + tags.Genres ??= mediaInfo.Genres; + tags.Track = tags.Track == 0U ? Convert.ToUInt32(mediaInfo.IndexNumber, CultureInfo.InvariantCulture) : tags.Track; + tags.Disc = tags.Disc == 0U ? Convert.ToUInt32(mediaInfo.ParentIndexNumber, CultureInfo.InvariantCulture) : tags.Disc; + + if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast)) { - if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast)) + var people = new List<PersonInfo>(); + var albumArtists = tags.AlbumArtists; + foreach (var albumArtist in albumArtists) { - var people = new List<PersonInfo>(); - var albumArtists = tags.AlbumArtists; - foreach (var albumArtist in albumArtists) + if (!string.IsNullOrEmpty(albumArtist)) { - if (!string.IsNullOrEmpty(albumArtist)) + PeopleHelper.AddPerson(people, new PersonInfo { - PeopleHelper.AddPerson(people, new PersonInfo - { - Name = albumArtist, - Type = PersonKind.AlbumArtist - }); - } + Name = albumArtist, + Type = PersonKind.AlbumArtist + }); } + } - var performers = tags.Performers; - foreach (var performer in performers) + var performers = tags.Performers; + foreach (var performer in performers) + { + if (!string.IsNullOrEmpty(performer)) { - if (!string.IsNullOrEmpty(performer)) + PeopleHelper.AddPerson(people, new PersonInfo { - PeopleHelper.AddPerson(people, new PersonInfo - { - Name = performer, - Type = PersonKind.Artist - }); - } + Name = performer, + Type = PersonKind.Artist + }); } + } - foreach (var composer in tags.Composers) + foreach (var composer in tags.Composers) + { + if (!string.IsNullOrEmpty(composer)) { - if (!string.IsNullOrEmpty(composer)) + PeopleHelper.AddPerson(people, new PersonInfo { - PeopleHelper.AddPerson(people, new PersonInfo - { - Name = composer, - Type = PersonKind.Composer - }); - } - } - - _libraryManager.UpdatePeople(audio, people); - - if (options.ReplaceAllMetadata && performers.Length != 0) - { - audio.Artists = performers; - } - else if (!options.ReplaceAllMetadata - && (audio.Artists is null || audio.Artists.Count == 0)) - { - audio.Artists = performers; + Name = composer, + Type = PersonKind.Composer + }); } + } - if (albumArtists.Length == 0) - { - // Album artists not provided, fall back to performers (artists). - albumArtists = performers; - } + _libraryManager.UpdatePeople(audio, people); - if (options.ReplaceAllMetadata && albumArtists.Length != 0) - { - audio.AlbumArtists = albumArtists; - } - else if (!options.ReplaceAllMetadata - && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0)) - { - audio.AlbumArtists = albumArtists; - } + if (options.ReplaceAllMetadata && performers.Length != 0) + { + audio.Artists = performers; + } + else if (!options.ReplaceAllMetadata + && (audio.Artists is null || audio.Artists.Count == 0)) + { + audio.Artists = performers; } - if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(tags.Title)) + if (albumArtists.Length == 0) { - audio.Name = tags.Title; + // Album artists not provided, fall back to performers (artists). + albumArtists = performers; } - if (options.ReplaceAllMetadata) + if (options.ReplaceAllMetadata && albumArtists.Length != 0) { - audio.Album = tags.Album; - audio.IndexNumber = Convert.ToInt32(tags.Track); - audio.ParentIndexNumber = Convert.ToInt32(tags.Disc); + audio.AlbumArtists = albumArtists; } - else + else if (!options.ReplaceAllMetadata + && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0)) { - audio.Album ??= tags.Album; - audio.IndexNumber ??= Convert.ToInt32(tags.Track); - audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc); + audio.AlbumArtists = albumArtists; } + } - if (tags.Year != 0) - { - var year = Convert.ToInt32(tags.Year); - audio.ProductionYear = year; + if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(tags.Title)) + { + audio.Name = tags.Title; + } - if (!audio.PremiereDate.HasValue) + if (options.ReplaceAllMetadata) + { + audio.Album = tags.Album; + audio.IndexNumber = Convert.ToInt32(tags.Track); + audio.ParentIndexNumber = Convert.ToInt32(tags.Disc); + } + else + { + audio.Album ??= tags.Album; + audio.IndexNumber ??= Convert.ToInt32(tags.Track); + audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc); + } + + if (tags.Year != 0) + { + var year = Convert.ToInt32(tags.Year); + audio.ProductionYear = year; + + if (!audio.PremiereDate.HasValue) + { + try { audio.PremiereDate = new DateTime(year, 01, 01); } + catch (ArgumentOutOfRangeException ex) + { + _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, tags.Year); + } } + } - if (!audio.LockedFields.Contains(MetadataField.Genres)) - { - audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0 - ? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray() - : audio.Genres; - } + if (!audio.LockedFields.Contains(MetadataField.Genres)) + { + audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0 + ? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray() + : audio.Genres; + } - if (!double.IsNaN(tags.ReplayGainTrackGain)) - { - audio.NormalizationGain = (float)tags.ReplayGainTrackGain; - } + if (!double.IsNaN(tags.ReplayGainTrackGain)) + { + audio.NormalizationGain = (float)tags.ReplayGainTrackGain; + } - if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _)) - { - audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId); - } + if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _)) + && !string.IsNullOrEmpty(tags.MusicBrainzArtistId)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId); + } - if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _)) - { - audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId); - } + if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _)) + && !string.IsNullOrEmpty(tags.MusicBrainzReleaseArtistId)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId); + } - if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _)) - { - audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId); - } + if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _)) + && !string.IsNullOrEmpty(tags.MusicBrainzReleaseId)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId); + } - if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _)) - { - audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId); - } + if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _)) + && !string.IsNullOrEmpty(tags.MusicBrainzReleaseGroupId)) + { + audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId); + } - if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _)) + if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _)) + { + // Fallback to ffprobe as TagLib incorrectly provides recording MBID in `tags.MusicBrainzTrackId`. + // See https://github.com/mono/taglib-sharp/issues/304 + var trackMbId = mediaInfo.GetProviderId(MetadataProvider.MusicBrainzTrack); + if (trackMbId is not null) { - // Fallback to ffprobe as TagLib incorrectly provides recording MBID in `tags.MusicBrainzTrackId`. - // See https://github.com/mono/taglib-sharp/issues/304 - var trackMbId = mediaInfo.GetProviderId(MetadataProvider.MusicBrainzTrack); - if (trackMbId is not null) - { - audio.SetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId); - } + audio.SetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId); } + } - // Save extracted lyrics if they exist, - // and if the audio doesn't yet have lyrics. - if (!string.IsNullOrWhiteSpace(tags.Lyrics) - && tryExtractEmbeddedLyrics) - { - await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false); - } + // Save extracted lyrics if they exist, + // and if the audio doesn't yet have lyrics. + if (!string.IsNullOrWhiteSpace(tags.Lyrics) + && tryExtractEmbeddedLyrics) + { + await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false); } } @@ -342,7 +378,10 @@ namespace MediaBrowser.Providers.MediaInfo var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, false); audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray(); - currentStreams.AddRange(externalLyricFiles); + if (externalLyricFiles.Count > 0) + { + currentStreams.Add(externalLyricFiles[0]); + } } } } diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 5d0fccbe1..246ba2733 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -124,11 +124,8 @@ namespace MediaBrowser.Providers.MediaInfo // Get BD disc information blurayDiscInfo = GetBDInfo(item.Path); - // Get playable .m2ts files - var m2ts = _mediaEncoder.GetPrimaryPlaylistM2tsFiles(item.Path); - // Return if no playable .m2ts files are found - if (blurayDiscInfo is null || blurayDiscInfo.Files.Length == 0 || m2ts.Count == 0) + if (blurayDiscInfo is null || blurayDiscInfo.Files.Length == 0) { _logger.LogError("No playable .m2ts files found in Blu-ray structure, skipping FFprobe."); return ItemUpdateType.MetadataImport; @@ -138,7 +135,7 @@ namespace MediaBrowser.Providers.MediaInfo mediaInfoResult = await GetMediaInfo( new Video { - Path = m2ts[0] + Path = blurayDiscInfo.Files[0] }, cancellationToken).ConfigureAwait(false); } @@ -358,6 +355,10 @@ namespace MediaBrowser.Providers.MediaInfo blurayVideoStream.BitRate = blurayVideoStream.BitRate.GetValueOrDefault() == 0 ? ffmpegVideoStream.BitRate : blurayVideoStream.BitRate; blurayVideoStream.Width = blurayVideoStream.Width.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Width; blurayVideoStream.Height = blurayVideoStream.Height.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Height; + blurayVideoStream.ColorRange = ffmpegVideoStream.ColorRange; + blurayVideoStream.ColorSpace = ffmpegVideoStream.ColorSpace; + blurayVideoStream.ColorTransfer = ffmpegVideoStream.ColorTransfer; + blurayVideoStream.ColorPrimaries = ffmpegVideoStream.ColorPrimaries; } } diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index b330b419b..04da8fb88 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -1,6 +1,7 @@ #nullable disable using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; @@ -103,6 +104,7 @@ namespace MediaBrowser.Providers.MediaInfo _subtitleResolver); _audioProber = new AudioFileProber( + loggerFactory.CreateLogger<AudioFileProber>(), mediaSourceManager, mediaEncoder, itemRepo, @@ -140,19 +142,15 @@ namespace MediaBrowser.Providers.MediaInfo && item.SupportsLocalMetadata && !video.IsPlaceHolder) { - if (!video.SubtitleFiles.SequenceEqual( - _subtitleResolver.GetExternalFiles(video, directoryService, false) - .Select(info => info.Path).ToList(), - StringComparer.Ordinal)) + var externalFiles = new HashSet<string>(_subtitleResolver.GetExternalFiles(video, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase); + if (!new HashSet<string>(video.SubtitleFiles, StringComparer.Ordinal).SetEquals(externalFiles)) { _logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path); return true; } - if (!video.AudioFiles.SequenceEqual( - _audioResolver.GetExternalFiles(video, directoryService, false) - .Select(info => info.Path).ToList(), - StringComparer.Ordinal)) + externalFiles = new HashSet<string>(_audioResolver.GetExternalFiles(video, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase); + if (!new HashSet<string>(video.AudioFiles, StringComparer.Ordinal).SetEquals(externalFiles)) { _logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path); return true; @@ -160,14 +158,14 @@ namespace MediaBrowser.Providers.MediaInfo } if (item is Audio audio - && item.SupportsLocalMetadata - && !audio.LyricFiles.SequenceEqual( - _lyricResolver.GetExternalFiles(audio, directoryService, false) - .Select(info => info.Path).ToList(), - StringComparer.Ordinal)) + && item.SupportsLocalMetadata) { - _logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path); - return true; + var externalFiles = new HashSet<string>(_lyricResolver.GetExternalFiles(audio, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase); + if (!new HashSet<string>(audio.LyricFiles, StringComparer.Ordinal).SetEquals(externalFiles)) + { + _logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path); + return true; + } } return false; diff --git a/MediaBrowser.Providers/Movies/MovieMetadataService.cs b/MediaBrowser.Providers/Movies/MovieMetadataService.cs index 984a3c122..8997ddc64 100644 --- a/MediaBrowser.Providers/Movies/MovieMetadataService.cs +++ b/MediaBrowser.Providers/Movies/MovieMetadataService.cs @@ -24,22 +24,6 @@ namespace MediaBrowser.Providers.Movies } /// <inheritdoc /> - protected override bool IsFullLocalMetadata(Movie item) - { - if (string.IsNullOrWhiteSpace(item.Overview)) - { - return false; - } - - if (!item.ProductionYear.HasValue) - { - return false; - } - - return base.IsFullLocalMetadata(item); - } - - /// <inheritdoc /> protected override void MergeData(MetadataResult<Movie> source, MetadataResult<Movie> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); diff --git a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs index ad0c5aaa7..e77d2fa8a 100644 --- a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs +++ b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 +using System.Linq; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -24,22 +25,6 @@ namespace MediaBrowser.Providers.Movies } /// <inheritdoc /> - protected override bool IsFullLocalMetadata(Trailer item) - { - if (string.IsNullOrWhiteSpace(item.Overview)) - { - return false; - } - - if (!item.ProductionYear.HasValue) - { - return false; - } - - return base.IsFullLocalMetadata(item); - } - - /// <inheritdoc /> protected override void MergeData(MetadataResult<Trailer> source, MetadataResult<Trailer> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings) { base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings); @@ -48,6 +33,10 @@ namespace MediaBrowser.Providers.Movies { target.Item.TrailerTypes = source.Item.TrailerTypes; } + else + { + target.Item.TrailerTypes = target.Item.TrailerTypes.Concat(source.Item.TrailerTypes).Distinct().ToArray(); + } } } } diff --git a/MediaBrowser.Providers/Music/AlbumMetadataService.cs b/MediaBrowser.Providers/Music/AlbumMetadataService.cs index e4f34776b..a39bd16ce 100644 --- a/MediaBrowser.Providers/Music/AlbumMetadataService.cs +++ b/MediaBrowser.Providers/Music/AlbumMetadataService.cs @@ -225,6 +225,10 @@ namespace MediaBrowser.Providers.Music { targetItem.Artists = sourceItem.Artists; } + else + { + targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray(); + } if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) { diff --git a/MediaBrowser.Providers/Music/AudioMetadataService.cs b/MediaBrowser.Providers/Music/AudioMetadataService.cs index a5b7cb895..7b25bc0e4 100644 --- a/MediaBrowser.Providers/Music/AudioMetadataService.cs +++ b/MediaBrowser.Providers/Music/AudioMetadataService.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; @@ -60,6 +61,10 @@ namespace MediaBrowser.Providers.Music { targetItem.Artists = sourceItem.Artists; } + else + { + targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray(); + } if (replaceData || string.IsNullOrEmpty(targetItem.Album)) { diff --git a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs index b97b76630..24c4b5501 100644 --- a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs +++ b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 +using System.Linq; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -45,6 +46,10 @@ namespace MediaBrowser.Providers.Music { targetItem.Artists = sourceItem.Artists; } + else + { + targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray(); + } } } } diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs index 6256bfa21..51a3ba0c7 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs @@ -1,184 +1,227 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using PlaylistsNET.Content; -namespace MediaBrowser.Providers.Playlists +namespace MediaBrowser.Providers.Playlists; + +/// <summary> +/// Local playlist provider. +/// </summary> +public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>, + IHasOrder, + IForcedProvider, + IHasItemChangeMonitor { - public class PlaylistItemsProvider : ICustomMetadataProvider<Playlist>, - IHasOrder, - IForcedProvider, - IPreRefreshProvider, - IHasItemChangeMonitor + private readonly IFileSystem _fileSystem; + private readonly ILibraryManager _libraryManager; + private readonly ILogger<PlaylistItemsProvider> _logger; + private readonly CollectionType[] _ignoredCollections = [CollectionType.livetv, CollectionType.boxsets, CollectionType.playlists]; + + /// <summary> + /// Initializes a new instance of the <see cref="PlaylistItemsProvider"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{PlaylistItemsProvider}"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + public PlaylistItemsProvider(ILogger<PlaylistItemsProvider> logger, ILibraryManager libraryManager, IFileSystem fileSystem) { - private readonly ILogger<PlaylistItemsProvider> _logger; - - public PlaylistItemsProvider(ILogger<PlaylistItemsProvider> logger) - { - _logger = logger; - } + _logger = logger; + _libraryManager = libraryManager; + _fileSystem = fileSystem; + } - public string Name => "Playlist Reader"; + /// <inheritdoc /> + public string Name => "Playlist Item Provider"; - // Run last - public int Order => 100; + /// <inheritdoc /> + public int Order => 100; - public Task<ItemUpdateType> FetchAsync(Playlist item, MetadataRefreshOptions options, CancellationToken cancellationToken) + /// <inheritdoc /> + public Task<MetadataResult<Playlist>> GetMetadata( + ItemInfo info, + IDirectoryService directoryService, + CancellationToken cancellationToken) + { + var result = new MetadataResult<Playlist>() { - var path = item.Path; - if (!Playlist.IsPlaylistFile(path)) + Item = new Playlist { - return Task.FromResult(ItemUpdateType.None); + Path = info.Path } + }; + Fetch(result); - var extension = Path.GetExtension(path); - if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase)) - { - return Task.FromResult(ItemUpdateType.None); - } + return Task.FromResult(result); + } - var items = GetItems(path, extension).ToArray(); + private void Fetch(MetadataResult<Playlist> result) + { + var item = result.Item; + var path = item.Path; + if (!Playlist.IsPlaylistFile(path)) + { + return; + } - item.LinkedChildren = items; + var extension = Path.GetExtension(path); + if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + { + return; + } - return Task.FromResult(ItemUpdateType.None); + var items = GetItems(path, extension).ToArray(); + if (items.Length > 0) + { + result.HasMetadata = true; + item.LinkedChildren = items; } - private IEnumerable<LinkedChild> GetItems(string path, string extension) + return; + } + + private IEnumerable<LinkedChild> GetItems(string path, string extension) + { + var libraryRoots = _libraryManager.GetUserRootFolder().Children + .OfType<CollectionFolder>() + .Where(f => f.CollectionType.HasValue && !_ignoredCollections.Contains(f.CollectionType.Value)) + .SelectMany(f => f.PhysicalLocations) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + using (var stream = File.OpenRead(path)) { - using (var stream = File.OpenRead(path)) + if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase)) { - if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase)) - { - return GetWplItems(stream, path); - } - - if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase)) - { - return GetZplItems(stream, path); - } - - if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase)) - { - return GetM3uItems(stream, path); - } - - if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase)) - { - return GetM3u8Items(stream, path); - } - - if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase)) - { - return GetPlsItems(stream, path); - } + return GetWplItems(stream, path, libraryRoots); } - return Enumerable.Empty<LinkedChild>(); - } - - private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string path) - { - var content = new PlsContent(); - var playlist = content.GetFromStream(stream); + if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase)) + { + return GetZplItems(stream, path, libraryRoots); + } - return playlist.PlaylistEntries.Select(i => new LinkedChild + if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase)) { - Path = GetPlaylistItemPath(i.Path, path), - Type = LinkedChildType.Manual - }); - } + return GetM3uItems(stream, path, libraryRoots); + } - private IEnumerable<LinkedChild> GetM3u8Items(Stream stream, string path) - { - var content = new M3uContent(); - var playlist = content.GetFromStream(stream); + if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase)) + { + return GetM3uItems(stream, path, libraryRoots); + } - return playlist.PlaylistEntries.Select(i => new LinkedChild + if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase)) { - Path = GetPlaylistItemPath(i.Path, path), - Type = LinkedChildType.Manual - }); + return GetPlsItems(stream, path, libraryRoots); + } } - private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string path) - { - var content = new M3uContent(); - var playlist = content.GetFromStream(stream); + return Enumerable.Empty<LinkedChild>(); + } - return playlist.PlaylistEntries.Select(i => new LinkedChild - { - Path = GetPlaylistItemPath(i.Path, path), - Type = LinkedChildType.Manual - }); - } + private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots) + { + var content = new PlsContent(); + var playlist = content.GetFromStream(stream); - private IEnumerable<LinkedChild> GetZplItems(Stream stream, string path) - { - var content = new ZplContent(); - var playlist = content.GetFromStream(stream); + return playlist.PlaylistEntries + .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) + .Where(i => i is not null); + } + + private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots) + { + var content = new M3uContent(); + var playlist = content.GetFromStream(stream); + + return playlist.PlaylistEntries + .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) + .Where(i => i is not null); + } + + private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots) + { + var content = new ZplContent(); + var playlist = content.GetFromStream(stream); + + return playlist.PlaylistEntries + .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) + .Where(i => i is not null); + } + + private IEnumerable<LinkedChild> GetWplItems(Stream stream, string playlistPath, List<string> libraryRoots) + { + var content = new WplContent(); + var playlist = content.GetFromStream(stream); + + return playlist.PlaylistEntries + .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots)) + .Where(i => i is not null); + } - return playlist.PlaylistEntries.Select(i => new LinkedChild + private LinkedChild GetLinkedChild(string itemPath, string playlistPath, List<string> libraryRoots) + { + if (TryGetPlaylistItemPath(itemPath, playlistPath, libraryRoots, out var parsedPath)) + { + return new LinkedChild { - Path = GetPlaylistItemPath(i.Path, path), + Path = parsedPath, Type = LinkedChildType.Manual - }); + }; } - private IEnumerable<LinkedChild> GetWplItems(Stream stream, string path) - { - var content = new WplContent(); - var playlist = content.GetFromStream(stream); + return null; + } - return playlist.PlaylistEntries.Select(i => new LinkedChild - { - Path = GetPlaylistItemPath(i.Path, path), - Type = LinkedChildType.Manual - }); + private bool TryGetPlaylistItemPath(string itemPath, string playlistPath, List<string> libraryPaths, out string path) + { + path = null; + string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath); + if (!File.Exists(pathToCheck)) + { + return false; } - private string GetPlaylistItemPath(string itemPath, string containingPlaylistFolder) + foreach (var libraryPath in libraryPaths) { - if (!File.Exists(itemPath)) + if (pathToCheck.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase)) { - var path = Path.Combine(Path.GetDirectoryName(containingPlaylistFolder), itemPath); - if (File.Exists(path)) - { - return path; - } + path = pathToCheck; + return true; } - - return itemPath; } - public bool HasChanged(BaseItem item, IDirectoryService directoryService) - { - var path = item.Path; + return false; + } - if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol) + /// <inheritdoc /> + public bool HasChanged(BaseItem item, IDirectoryService directoryService) + { + var path = item.Path; + if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol) + { + var file = directoryService.GetFile(path); + if (file is not null && file.LastWriteTimeUtc != item.DateModified) { - var file = directoryService.GetFile(path); - if (file is not null && file.LastWriteTimeUtc != item.DateModified) - { - _logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path); - return true; - } + _logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path); + return true; } - - return false; } + + return false; } } diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs index 1bd000a48..43889bfbf 100644 --- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs +++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs @@ -1,6 +1,7 @@ #pragma warning disable CS1591 using System.Collections.Generic; +using System.Linq; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -49,8 +50,24 @@ namespace MediaBrowser.Providers.Playlists if (mergeMetadataSettings) { targetItem.PlaylistMediaType = sourceItem.PlaylistMediaType; - targetItem.LinkedChildren = sourceItem.LinkedChildren; - targetItem.Shares = sourceItem.Shares; + + if (replaceData || targetItem.LinkedChildren.Length == 0) + { + targetItem.LinkedChildren = sourceItem.LinkedChildren; + } + else + { + targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray(); + } + + if (replaceData || targetItem.Shares.Count == 0) + { + targetItem.Shares = sourceItem.Shares; + } + else + { + targetItem.Shares = sourceItem.Shares.Concat(targetItem.Shares).DistinctBy(s => s.UserId).ToArray(); + } } } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index d0bd7d609..c35324746 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -250,7 +250,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu // If we have a release ID but not a release group ID, lookup the release group if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) { - var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Releases, cancellationToken).ConfigureAwait(false); + var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); releaseGroupId = release.ReleaseGroup?.Id.ToString(); result.HasMetadata = true; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 8dc2d6938..d8476bd47 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -278,17 +278,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.RunTimeTicks = seriesResult.EpisodeRunTime.Select(i => TimeSpan.FromMinutes(i).Ticks).FirstOrDefault(); - if (string.Equals(seriesResult.Status, "Ended", StringComparison.OrdinalIgnoreCase) - || string.Equals(seriesResult.Status, "Canceled", StringComparison.OrdinalIgnoreCase)) + if (Emby.Naming.TV.TvParserHelpers.TryParseSeriesStatus(seriesResult.Status, out var seriesStatus)) { - series.Status = SeriesStatus.Ended; - series.EndDate = seriesResult.LastAirDate; - } - else - { - series.Status = SeriesStatus.Continuing; + series.Status = seriesStatus; } + series.EndDate = seriesResult.LastAirDate; series.PremiereDate = seriesResult.FirstAirDate; var ids = seriesResult.ExternalIds; diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 0acc1c805..b03d6ffb5 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -61,24 +61,8 @@ namespace MediaBrowser.Providers.TV await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false); RemoveObsoleteEpisodes(item); + await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false); RemoveObsoleteSeasons(item); - await UpdateAndCreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false); - } - - /// <inheritdoc /> - protected override bool IsFullLocalMetadata(Series item) - { - if (string.IsNullOrWhiteSpace(item.Overview)) - { - return false; - } - - if (!item.ProductionYear.HasValue) - { - return false; - } - - return base.IsFullLocalMetadata(item); } /// <inheritdoc /> @@ -88,20 +72,6 @@ namespace MediaBrowser.Providers.TV var sourceItem = source.Item; var targetItem = target.Item; - var sourceSeasonNames = sourceItem.SeasonNames; - var targetSeasonNames = targetItem.SeasonNames; - - if (replaceData || targetSeasonNames.Count == 0) - { - targetItem.SeasonNames = sourceSeasonNames; - } - else if (targetSeasonNames.Count != sourceSeasonNames.Count || !sourceSeasonNames.Keys.All(targetSeasonNames.ContainsKey)) - { - foreach (var (number, name) in sourceSeasonNames) - { - targetSeasonNames.TryAdd(number, name); - } - } if (replaceData || string.IsNullOrEmpty(targetItem.AirTime)) { @@ -121,7 +91,7 @@ namespace MediaBrowser.Providers.TV private void RemoveObsoleteSeasons(Series series) { - // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in UpdateAndCreateSeasonsAsync. + // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in CreateSeasonsAsync. var physicalSeasonNumbers = new HashSet<int>(); var virtualSeasons = new List<Season>(); foreach (var existingSeason in series.Children.OfType<Season>()) @@ -149,7 +119,8 @@ namespace MediaBrowser.Providers.TV virtualSeason, new DeleteOptions { - DeleteFileLocation = true + // Internal metadata paths are removed regardless of this. + DeleteFileLocation = false }, false); } @@ -158,7 +129,7 @@ namespace MediaBrowser.Providers.TV private void RemoveObsoleteEpisodes(Series series) { - var episodes = series.GetEpisodes(null, new DtoOptions()).OfType<Episode>().ToList(); + var episodes = series.GetEpisodes(null, new DtoOptions(), true).OfType<Episode>().ToList(); var numberOfEpisodes = episodes.Count; // TODO: O(n^2), but can it be done faster without overcomplicating it? for (var i = 0; i < numberOfEpisodes; i++) @@ -206,7 +177,8 @@ namespace MediaBrowser.Providers.TV episode, new DeleteOptions { - DeleteFileLocation = true + // Internal metadata paths are removed regardless of this. + DeleteFileLocation = false }, false); } @@ -214,14 +186,12 @@ namespace MediaBrowser.Providers.TV /// <summary> /// Creates seasons for all episodes if they don't exist. /// If no season number can be determined, a dummy season will be created. - /// Updates seasons names. /// </summary> /// <param name="series">The series.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The async task.</returns> - private async Task UpdateAndCreateSeasonsAsync(Series series, CancellationToken cancellationToken) + private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken) { - var seasonNames = series.SeasonNames; var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season); var seasons = seriesChildren.OfType<Season>().ToList(); var uniqueSeasonNumbers = seriesChildren @@ -234,20 +204,14 @@ namespace MediaBrowser.Providers.TV { // Null season numbers will have a 'dummy' season created because seasons are always required. var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber); - - if (!seasonNumber.HasValue || !seasonNames.TryGetValue(seasonNumber.Value, out var seasonName)) - { - seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber); - } - if (existingSeason is null) { - var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false); - series.AddChild(season); + var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber); + await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false); } - else if (!string.Equals(existingSeason.Name, seasonName, StringComparison.Ordinal)) + else if (existingSeason.IsVirtualItem) { - existingSeason.Name = seasonName; + existingSeason.IsVirtualItem = false; await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); } } @@ -261,7 +225,7 @@ namespace MediaBrowser.Providers.TV /// <param name="seasonNumber">The season number.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The newly created season.</returns> - private async Task<Season> CreateSeasonAsync( + private async Task CreateSeasonAsync( Series series, string? seasonName, int? seasonNumber, @@ -278,14 +242,12 @@ namespace MediaBrowser.Providers.TV typeof(Season)), IsVirtualItem = false, SeriesId = series.Id, - SeriesName = series.Name + SeriesName = series.Name, + SeriesPresentationUniqueKey = series.GetPresentationUniqueKey() }; series.AddChild(season); - await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false); - - return season; } private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber) diff --git a/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs index f6dcde4f6..9dc4446fc 100644 --- a/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs +++ b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs @@ -101,7 +101,7 @@ public class TrickplayProvider : ICustomMetadataProvider<Episode>, bool? enableDuringScan = libraryOptions?.ExtractTrickplayImagesDuringLibraryScan; bool replace = options.ReplaceAllImages; - if (options.IsAutomated && !enableDuringScan.GetValueOrDefault(false)) + if (!enableDuringScan.GetValueOrDefault(false)) { return ItemUpdateType.None; } diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 97cdc6854..d049c5a8e 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -519,7 +519,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (reader.TryReadDateTimeExact(nfoConfiguration.ReleaseDateFormat, out var releaseDate)) { item.PremiereDate = releaseDate; - item.ProductionYear = releaseDate.Year; + + // Production year can already be set by the year tag + item.ProductionYear ??= releaseDate.Year; } break; diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs index dbcfe7997..d99e11bcd 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs @@ -1,6 +1,6 @@ -using System; using System.Globalization; using System.Xml; +using Emby.Naming.TV; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Extensions; @@ -87,7 +87,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers if (!string.IsNullOrWhiteSpace(status)) { - if (Enum.TryParse(status, true, out SeriesStatus seriesStatus)) + if (TvParserHelpers.TryParseSeriesStatus(status, out var seriesStatus)) { item.Status = seriesStatus; } @@ -100,19 +100,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers break; } + // Season names are processed by SeriesNfoSeasonParser case "namedseason": - { - var parsed = int.TryParse(reader.GetAttribute("number"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber); - var name = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(name) && parsed) - { - item.SeasonNames[seasonNumber] = name; - } - - break; - } - + reader.Skip(); + break; default: base.FetchDataFromXmlNode(reader, itemResult); break; diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs new file mode 100644 index 000000000..44ca3f472 --- /dev/null +++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs @@ -0,0 +1,60 @@ +using System.Globalization; +using System.Xml; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.XbmcMetadata.Parsers +{ + /// <summary> + /// NFO parser for seasons based on series NFO. + /// </summary> + public class SeriesNfoSeasonParser : BaseNfoParser<Season> + { + /// <summary> + /// Initializes a new instance of the <see cref="SeriesNfoSeasonParser"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> + public SeriesNfoSeasonParser( + ILogger logger, + IConfigurationManager config, + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService) + : base(logger, config, providerManager, userManager, userDataManager, directoryService) + { + } + + /// <inheritdoc /> + protected override bool SupportsUrlAfterClosingXmlTag => true; + + /// <inheritdoc /> + protected override void FetchDataFromXmlNode(XmlReader reader, MetadataResult<Season> itemResult) + { + var item = itemResult.Item; + + if (reader.Name == "namedseason") + { + var parsed = int.TryParse(reader.GetAttribute("number"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber); + var name = reader.ReadElementContentAsString(); + + if (parsed && !string.IsNullOrWhiteSpace(name) && item.IndexNumber.HasValue && seasonNumber == item.IndexNumber.Value) + { + item.Name = name; + } + } + else + { + reader.Skip(); + } + } + } +} diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs index 9b4e1731d..22c065b5d 100644 --- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs @@ -42,7 +42,10 @@ namespace MediaBrowser.XbmcMetadata.Providers try { - result.Item = new T(); + result.Item = new T + { + IndexNumber = info.IndexNumber + }; Fetch(result, path, cancellationToken); result.HasMetadata = true; diff --git a/MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs b/MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs new file mode 100644 index 000000000..b141b7afb --- /dev/null +++ b/MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs @@ -0,0 +1,89 @@ +using System.IO; +using System.Threading; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.IO; +using MediaBrowser.XbmcMetadata.Parsers; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.XbmcMetadata.Providers +{ + /// <summary> + /// NFO provider for seasons based on series NFO. + /// </summary> + public class SeriesNfoSeasonProvider : BaseNfoProvider<Season> + { + private readonly ILogger<SeriesNfoSeasonProvider> _logger; + private readonly IConfigurationManager _config; + private readonly IProviderManager _providerManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly IDirectoryService _directoryService; + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SeriesNfoSeasonProvider"/> class. + /// </summary> + /// <param name="logger">Instance of the <see cref="ILogger{SeasonFromSeriesNfoProvider}"/> interface.</param> + /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> + /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param> + /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> + /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param> + public SeriesNfoSeasonProvider( + ILogger<SeriesNfoSeasonProvider> logger, + IFileSystem fileSystem, + IConfigurationManager config, + IProviderManager providerManager, + IUserManager userManager, + IUserDataManager userDataManager, + IDirectoryService directoryService, + ILibraryManager libraryManager) + : base(fileSystem) + { + _logger = logger; + _config = config; + _providerManager = providerManager; + _userManager = userManager; + _userDataManager = userDataManager; + _directoryService = directoryService; + _libraryManager = libraryManager; + } + + /// <inheritdoc /> + protected override void Fetch(MetadataResult<Season> result, string path, CancellationToken cancellationToken) + { + new SeriesNfoSeasonParser(_logger, _config, _providerManager, _userManager, _userDataManager, _directoryService).Fetch(result, path, cancellationToken); + } + + /// <inheritdoc /> + protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService) + { + var seasonPath = info.Path; + if (seasonPath is not null) + { + var path = Path.Combine(seasonPath, "tvshow.nfo"); + if (Path.Exists(path)) + { + return directoryService.GetFile(path); + } + } + + var seriesPath = _libraryManager.GetItemById(info.ParentId)?.Path; + if (seriesPath is not null) + { + var path = Path.Combine(seriesPath, "tvshow.nfo"); + if (Path.Exists(path)) + { + return directoryService.GetFile(path); + } + } + + return null; + } + } +} diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index b25cfc83f..a547779de 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -825,7 +825,7 @@ namespace MediaBrowser.XbmcMetadata.Savers private string GetOutputTrailerUrl(string url) { // This is what xbmc expects - return url.Replace(YouTubeWatchUrl, "plugin://plugin.video.youtube/?action=play_video&videoid=", StringComparison.OrdinalIgnoreCase); + return url.Replace(YouTubeWatchUrl, "plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase); } private void AddImages(BaseItem item, XmlWriter writer, ILibraryManager libraryManager) diff --git a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs index 8fa22fad9..bc344d87e 100644 --- a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs @@ -45,27 +45,24 @@ namespace MediaBrowser.XbmcMetadata.Savers internal static IEnumerable<string> GetMovieSavePaths(ItemInfo item) { + var path = item.ContainingFolderPath; if (item.VideoType == VideoType.Dvd && !item.IsPlaceHolder) { - var path = item.ContainingFolderPath; - yield return Path.Combine(path, "VIDEO_TS", "VIDEO_TS.nfo"); } - if (!item.IsPlaceHolder && (item.VideoType == VideoType.Dvd || item.VideoType == VideoType.BluRay)) + // only allow movie object to read movie.nfo, not owned videos (which will be itemtype video, not movie) + if (!item.IsInMixedFolder && item.ItemType == typeof(Movie)) { - var path = item.ContainingFolderPath; + yield return Path.Combine(path, "movie.nfo"); + } + if (!item.IsPlaceHolder && (item.VideoType == VideoType.Dvd || item.VideoType == VideoType.BluRay)) + { yield return Path.Combine(path, Path.GetFileName(path) + ".nfo"); } else { - // only allow movie object to read movie.nfo, not owned videos (which will be itemtype video, not movie) - if (!item.IsInMixedFolder && item.ItemType == typeof(Movie)) - { - yield return Path.Combine(item.ContainingFolderPath, "movie.nfo"); - } - yield return Path.ChangeExtension(item.Path, ".nfo"); } } @@ -16,9 +16,6 @@ <a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"> <img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/> </a> -<a href="https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=29"> -<img alt="Azure Builds" src="https://dev.azure.com/jellyfin-project/jellyfin/_apis/build/status/Jellyfin%20Server"/> -</a> <a href="https://hub.docker.com/r/jellyfin/jellyfin"> <img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/> </a> @@ -30,10 +27,7 @@ <img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/> </a> <a href="https://matrix.to/#/#jellyfinorg:matrix.org"> -<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/> -</a> -<a href="https://www.reddit.com/r/jellyfin"> -<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/> +<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/> </a> <a href="https://github.com/jellyfin/jellyfin/releases.atom"> <img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /> @@ -153,20 +147,20 @@ API documentation can be viewed at `http://localhost:8096/api-docs/swagger/index As Jellyfin will run on a container on a github hosted server, JF needs to handle some things differently. -**NOTE:** Depending on the selected configuration (if you just click 'create codespace' it will create a default configuration one) it might take 20-30 secounds to load all extensions and prepare the enviorment while vscode is already open. Just give it some time and wait until you see `Downloading .NET version(s) 7.0.15~x64 ...... Done!` in the output tab. +**NOTE:** Depending on the selected configuration (if you just click 'create codespace' it will create a default configuration one) it might take 20-30 seconds to load all extensions and prepare the environment while VS Code is already open. Just give it some time and wait until you see `Downloading .NET version(s) 7.0.15~x64 ...... Done!` in the output tab. -**NOTE:** If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VsCode window to public. +**NOTE:** If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VS Code window to public. -**NOTE:** When first opening the server instance with any WebUI, you will be send to the login instead of the setup page. Refresh the login page once and you should be redirected to the Setup. +**NOTE:** When first opening the server instance with any WebUI, you will be sent to the login instead of the setup page. Refresh the login page once and you should be redirected to the Setup. -There are two configurations for you to chose from. +There are two configurations for you to choose from. #### Default - Development Jellyfin Server -This creates a container that has everything to run and debug the Jellyfin Media server but does not setup anything else. Each time you create a new container you have to run though the whole setup again. There is also no ffmpeg, webclient or media preloaded. Use the `.NET Launch (nowebclient)` lunch config to start the server. +This creates a container that has everything to run and debug the Jellyfin Media server but does not setup anything else. Each time you create a new container you have to run through the whole setup again. There is also no ffmpeg, webclient or media preloaded. Use the `.NET Launch (nowebclient)` launch config to start the server. -> Keep in mind that as this has no web client you have to connect to it via an extenal client. This can be just another codespace container running the WebUI. vuejs does not work from the getgo as it does not support the setup steps. +> Keep in mind that as this has no web client you have to connect to it via an external client. This can be just another codespace container running the WebUI. vuejs does not work from the get-go as it does not support the setup steps. #### Development Jellyfin Server ffmpeg -this extens the default server with an default installation of ffmpeg6 though the means described here: https://jellyfin.org/docs/general/installation/linux#repository-manual +this extends the default server with a default installation of ffmpeg6 though the means described here: https://jellyfin.org/docs/general/installation/linux#repository-manual If you want to install a specific ffmpeg version, follow the comments embedded in the `.devcontainer/Dev - Server Ffmpeg/install.ffmpeg.sh` file. Use the `ghcs .NET Launch (nowebclient, ffmpeg)` launch config to run with the jellyfin-ffmpeg enabled. diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 75963226a..ede93aaa5 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -219,7 +219,7 @@ public class SkiaEncoder : IImageEncoder return path; } - var tempPath = Path.Combine(_appPaths.TempDirectory, string.Concat(Guid.NewGuid().ToString(), Path.GetExtension(path.AsSpan()))); + var tempPath = Path.Join(_appPaths.TempDirectory, string.Concat("skia_", Guid.NewGuid().ToString(), Path.GetExtension(path.AsSpan()))); var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid."); Directory.CreateDirectory(directory); File.Copy(path, tempPath, true); diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index 182996852..0cfac384e 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; +using System.Threading; namespace Jellyfin.Extensions { @@ -48,11 +50,12 @@ namespace Jellyfin.Extensions /// Reads all lines in the <see cref="TextReader" />. /// </summary> /// <param name="reader">The <see cref="TextReader" /> to read from.</param> + /// <param name="cancellationToken">The token to monitor for cancellation requests.</param> /// <returns>All lines in the stream.</returns> - public static async IAsyncEnumerable<string> ReadAllLinesAsync(this TextReader reader) + public static async IAsyncEnumerable<string> ReadAllLinesAsync(this TextReader reader, [EnumeratorCancellation] CancellationToken cancellationToken = default) { string? line; - while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null) + while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null) { yield return line; } diff --git a/src/Jellyfin.LiveTv/Timers/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs index da5deea36..1cf335159 100644 --- a/src/Jellyfin.LiveTv/Timers/TimerManager.cs +++ b/src/Jellyfin.LiveTv/Timers/TimerManager.cs @@ -22,7 +22,7 @@ namespace Jellyfin.LiveTv.Timers public TimerManager(ILogger<TimerManager> logger, IConfigurationManager config) : base( logger, - Path.Combine(config.CommonApplicationPaths.DataPath, "livetv"), + Path.Combine(config.CommonApplicationPaths.DataPath, "livetv/timers.json"), (r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase)) { } diff --git a/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs index afc2e4f9c..aba9627ba 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs @@ -219,7 +219,7 @@ namespace Jellyfin.LiveTv.TunerHosts } } - throw new LiveTvConflictException(); + throw new LiveTvConflictException("Unable to find host to play channel"); } protected virtual bool IsValidChannelId(string channelId) diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index 861338727..1dd35da41 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -145,7 +145,7 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun } _activeTuner = -1; - throw new LiveTvConflictException(); + throw new LiveTvConflictException("No tuners available"); } public async Task ChangeChannel(IHdHomerunChannelCommands commands, CancellationToken cancellationToken) diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs index b78b60c70..365f0188d 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs @@ -30,23 +30,8 @@ namespace Jellyfin.LiveTv.TunerHosts { public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost { - private static readonly string[] _disallowedMimeTypes = - { - "video/x-matroska", - "video/mp4", - "application/vnd.apple.mpegurl", - "application/mpegurl", - "application/x-mpegurl", - "video/vnd.mpeg.dash.mpd" - }; - - private static readonly string[] _disallowedSharedStreamExtensions = - { - ".mkv", - ".mp4", - ".m3u8", - ".mpd" - }; + private static readonly string[] _mimeTypesCanShareHttpStream = ["video/MP2T"]; + private static readonly string[] _extensionsCanShareHttpStream = [".ts", ".tsv", ".m2t"]; private readonly IHttpClientFactory _httpClientFactory; private readonly IServerApplicationHost _appHost; @@ -111,31 +96,33 @@ namespace Jellyfin.LiveTv.TunerHosts if (mediaSource.Protocol == MediaProtocol.Http && !mediaSource.RequiresLooping) { - using var message = new HttpRequestMessage(HttpMethod.Head, mediaSource.Path); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default) - .SendAsync(message, cancellationToken) - .ConfigureAwait(false); + var extension = Path.GetExtension(new UriBuilder(mediaSource.Path).Path); - if (response.IsSuccessStatusCode) + if (string.IsNullOrEmpty(extension)) { - if (!_disallowedMimeTypes.Contains(response.Content.Headers.ContentType?.ToString(), StringComparison.OrdinalIgnoreCase)) + try { - return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); + using var message = new HttpRequestMessage(HttpMethod.Head, mediaSource.Path); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(message, cancellationToken) + .ConfigureAwait(false); + + if (response.IsSuccessStatusCode) + { + if (_mimeTypesCanShareHttpStream.Contains(response.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase)) + { + return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); + } + } } - } - else if (response.StatusCode == HttpStatusCode.MethodNotAllowed || response.StatusCode == HttpStatusCode.NotImplemented) - { - // Fallback to check path extension when the server does not support HEAD method - // Use UriBuilder to remove all query string as GetExtension will include them when used directly - var extension = Path.GetExtension(new UriBuilder(mediaSource.Path).Path); - if (!_disallowedSharedStreamExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + catch (Exception) { - return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); + Logger.LogWarning("HEAD request to check MIME type failed, shared stream disabled"); } } - else + else if (_extensionsCanShareHttpStream.Contains(extension, StringComparison.OrdinalIgnoreCase)) { - response.EnsureSuccessStatusCode(); + return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper); } } diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs index 37160b3ba..cf6a2cc55 100644 --- a/src/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs @@ -11,7 +11,6 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; @@ -237,7 +236,7 @@ public class NetworkManager : INetworkManager, IDisposable var mac = adapter.GetPhysicalAddress(); // Populate MAC list - if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && PhysicalAddress.None.Equals(mac)) + if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && !PhysicalAddress.None.Equals(mac)) { macAddresses.Add(mac); } @@ -739,7 +738,9 @@ public class NetworkManager : INetworkManager, IDisposable /// <inheritdoc/> public IReadOnlyList<IPData> GetAllBindInterfaces(bool individualInterfaces = false) { - if (_interfaces.Count > 0 || individualInterfaces) + var config = _configurationManager.GetNetworkConfiguration(); + var localNetworkAddresses = config.LocalNetworkAddresses; + if ((localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0]) && _interfaces.Count > 0) || individualInterfaces) { return _interfaces; } @@ -902,15 +903,30 @@ public class NetworkManager : INetworkManager, IDisposable return false; } + /// <summary> + /// Get if the IPAddress is Link-local. + /// </summary> + /// <param name="address">The IP Address.</param> + /// <returns>Bool indicates if the address is link-local.</returns> + public bool IsLinkLocalAddress(IPAddress address) + { + ArgumentNullException.ThrowIfNull(address); + return NetworkConstants.IPv4RFC3927LinkLocal.Contains(address) || address.IsIPv6LinkLocal; + } + /// <inheritdoc/> public bool IsInLocalNetwork(IPAddress address) { ArgumentNullException.ThrowIfNull(address); - // See conversation at https://github.com/jellyfin/jellyfin/pull/3515. + // Map IPv6 mapped IPv4 back to IPv4 (happens if Kestrel runs in dual-socket mode) + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + if ((TrustAllIPv6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6) - || address.Equals(IPAddress.Loopback) - || address.Equals(IPAddress.IPv6Loopback)) + || IPAddress.IsLoopback(address)) { return true; } @@ -1083,7 +1099,11 @@ public class NetworkManager : INetworkManager, IDisposable private bool MatchesExternalInterface(IPAddress source, out string result) { // Get the first external interface address that isn't a loopback. - var extResult = _interfaces.Where(p => !IsInLocalNetwork(p.Address)).OrderBy(x => x.Index).ToArray(); + var extResult = _interfaces + .Where(p => !IsInLocalNetwork(p.Address)) + .Where(p => p.Address.AddressFamily.Equals(source.AddressFamily)) + .Where(p => !IsLinkLocalAddress(p.Address)) + .OrderBy(x => x.Index).ToArray(); // No external interface found if (extResult.Length == 0) @@ -1116,12 +1136,13 @@ public class NetworkManager : INetworkManager, IDisposable var logLevel = debug ? LogLevel.Debug : LogLevel.Information; if (_logger.IsEnabled(logLevel)) { - _logger.Log(logLevel, "Defined LAN addresses: {0}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); - _logger.Log(logLevel, "Defined LAN exclusions: {0}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); - _logger.Log(logLevel, "Using LAN addresses: {0}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength)); - _logger.Log(logLevel, "Using bind addresses: {0}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address)); - _logger.Log(logLevel, "Remote IP filter is {0}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist"); - _logger.Log(logLevel, "Filter list: {0}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Defined LAN subnets: {Subnets}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Defined LAN exclusions: {Subnets}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Used LAN subnets: {Subnets}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Filtered interface addresses: {Addresses}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address)); + _logger.Log(logLevel, "Bind Addresses {Addresses}", GetAllBindInterfaces(false).OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address)); + _logger.Log(logLevel, "Remote IP filter is {Type}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist"); + _logger.Log(logLevel, "Filtered subnets: {Subnets}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength)); } } } diff --git a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs index 3687d7753..31d2b486b 100644 --- a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs @@ -1,14 +1,19 @@ +using System; using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; using AutoFixture; using AutoFixture.AutoMoq; +using Jellyfin.Api.Auth.DefaultAuthorizationPolicy; using Jellyfin.Api.Auth.FirstTimeSetupPolicy; using Jellyfin.Api.Constants; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Library; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Moq; using Xunit; @@ -18,7 +23,9 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy { private readonly Mock<IConfigurationManager> _configurationManagerMock; private readonly List<IAuthorizationRequirement> _requirements; + private readonly DefaultAuthorizationHandler _defaultAuthorizationHandler; private readonly FirstTimeSetupHandler _firstTimeSetupHandler; + private readonly IAuthorizationService _authorizationService; private readonly Mock<IUserManager> _userManagerMock; private readonly Mock<IHttpContextAccessor> _httpContextAccessor; @@ -31,6 +38,21 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy _httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>(); _firstTimeSetupHandler = fixture.Create<FirstTimeSetupHandler>(); + _defaultAuthorizationHandler = fixture.Create<DefaultAuthorizationHandler>(); + + var services = new ServiceCollection(); + services.AddAuthorizationCore(); + services.AddLogging(); + services.AddOptions(); + services.AddSingleton<IAuthorizationHandler>(_defaultAuthorizationHandler); + services.AddSingleton<IAuthorizationHandler>(_firstTimeSetupHandler); + services.AddAuthorization(options => + { + options.AddPolicy("FirstTime", policy => policy.Requirements.Add(new FirstTimeSetupRequirement())); + options.AddPolicy("FirstTimeNoAdmin", policy => policy.Requirements.Add(new FirstTimeSetupRequirement(false, false))); + options.AddPolicy("FirstTimeSchedule", policy => policy.Requirements.Add(new FirstTimeSetupRequirement(true, false))); + }); + _authorizationService = services.BuildServiceProvider().GetRequiredService<IAuthorizationService>(); } [Theory] @@ -45,10 +67,9 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy _httpContextAccessor, userRole); - var context = new AuthorizationHandlerContext(_requirements, claims, null); + var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTime"); - await _firstTimeSetupHandler.HandleAsync(context); - Assert.True(context.HasSucceeded); + Assert.True(allowed.Succeeded); } [Theory] @@ -63,21 +84,43 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy _httpContextAccessor, userRole); - var context = new AuthorizationHandlerContext(_requirements, claims, null); + var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTime"); - await _firstTimeSetupHandler.HandleAsync(context); - Assert.Equal(shouldSucceed, context.HasSucceeded); + Assert.Equal(shouldSucceed, allowed.Succeeded); + } + + [Theory] + [InlineData(UserRoles.Administrator, true)] + [InlineData(UserRoles.Guest, false)] + [InlineData(UserRoles.User, true)] + public async Task ShouldRequireUserIfNotAdministrator(string userRole, bool shouldSucceed) + { + TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); + var claims = TestHelpers.SetupUser( + _userManagerMock, + _httpContextAccessor, + userRole); + + var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTimeNoAdmin"); + + Assert.Equal(shouldSucceed, allowed.Succeeded); } [Fact] - public async Task ShouldAllowAdminApiKeyIfStartupWizardComplete() + public async Task ShouldDisallowUserIfOutsideSchedule() { + AccessSchedule[] accessSchedules = { new AccessSchedule(DynamicDayOfWeek.Everyday, 0, 0, Guid.Empty) }; + TestHelpers.SetupConfigurationManager(_configurationManagerMock, true); - var claims = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Role, UserRoles.Administrator)])); - var context = new AuthorizationHandlerContext(_requirements, claims, null); + var claims = TestHelpers.SetupUser( + _userManagerMock, + _httpContextAccessor, + UserRoles.User, + accessSchedules); + + var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTimeSchedule"); - await _firstTimeSetupHandler.HandleAsync(context); - Assert.True(context.HasSucceeded); + Assert.False(allowed.Succeeded); } } } diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs index 46439aecb..83a023384 100644 --- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs +++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs @@ -80,6 +80,21 @@ namespace Jellyfin.Controller.Tests } [Fact] + public void GetDirectories_GivenPathsWithDifferentCasing_ReturnsCorrectDirectories() + { + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata); + var directoryService = new DirectoryService(fileSystemMock.Object); + + var upperCaseResult = directoryService.GetDirectories(UpperCasePath); + var lowerCaseResult = directoryService.GetDirectories(LowerCasePath); + + Assert.Equal(_upperCaseFileSystemMetadata.Where(f => f.IsDirectory), upperCaseResult); + Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => f.IsDirectory), lowerCaseResult); + } + + [Fact] public void GetFile_GivenFilePathsWithDifferentCasing_ReturnsCorrectFile() { const string lowerCasePath = "/music/someartist/song 1.mp3"; @@ -95,15 +110,52 @@ namespace Jellyfin.Controller.Tests Exists = false }; var fileSystemMock = new Mock<IFileSystem>(); - fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == upperCasePath))).Returns(upperCaseFileSystemMetadata); - fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == lowerCasePath))).Returns(lowerCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == upperCasePath))).Returns(upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == lowerCasePath))).Returns(lowerCaseFileSystemMetadata); + var directoryService = new DirectoryService(fileSystemMock.Object); + + var lowerCaseDirResult = directoryService.GetDirectory(lowerCasePath); + var lowerCaseFileResult = directoryService.GetFile(lowerCasePath); + var upperCaseDirResult = directoryService.GetDirectory(upperCasePath); + var upperCaseFileResult = directoryService.GetFile(upperCasePath); + + Assert.Null(lowerCaseDirResult); + Assert.Equal(lowerCaseFileSystemMetadata, lowerCaseFileResult); + Assert.Null(upperCaseDirResult); + Assert.Null(upperCaseFileResult); + } + + [Fact] + public void GetDirectory_GivenFilePathsWithDifferentCasing_ReturnsCorrectDirectory() + { + const string lowerCasePath = "/music/someartist/Lyrics"; + var lowerCaseFileSystemMetadata = new FileSystemMetadata + { + FullName = lowerCasePath, + IsDirectory = true, + Exists = true + }; + const string upperCasePath = "/music/SOMEARTIST/LYRICS"; + var upperCaseFileSystemMetadata = new FileSystemMetadata + { + FullName = upperCasePath, + IsDirectory = true, + Exists = false + }; + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == upperCasePath))).Returns(upperCaseFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == lowerCasePath))).Returns(lowerCaseFileSystemMetadata); var directoryService = new DirectoryService(fileSystemMock.Object); - var lowerCaseResult = directoryService.GetFile(lowerCasePath); - var upperCaseResult = directoryService.GetFile(upperCasePath); + var lowerCaseDirResult = directoryService.GetDirectory(lowerCasePath); + var lowerCaseFileResult = directoryService.GetFile(lowerCasePath); + var upperCaseDirResult = directoryService.GetDirectory(upperCasePath); + var upperCaseFileResult = directoryService.GetFile(upperCasePath); - Assert.Equal(lowerCaseFileSystemMetadata, lowerCaseResult); - Assert.Null(upperCaseResult); + Assert.Equal(lowerCaseFileSystemMetadata, lowerCaseDirResult); + Assert.Null(lowerCaseFileResult); + Assert.Null(upperCaseDirResult); + Assert.Null(upperCaseFileResult); } [Fact] @@ -122,11 +174,11 @@ namespace Jellyfin.Controller.Tests }; var fileSystemMock = new Mock<IFileSystem>(); - fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == path))).Returns(cachedFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == path))).Returns(cachedFileSystemMetadata); var directoryService = new DirectoryService(fileSystemMock.Object); var result = directoryService.GetFile(path); - fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == path))).Returns(newFileSystemMetadata); + fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == path))).Returns(newFileSystemMetadata); var secondResult = directoryService.GetFile(path); Assert.Equal(cachedFileSystemMetadata, result); diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs index 263f74c90..84008cffd 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs @@ -35,7 +35,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing Protocol = MediaProtocol.Http, RequiredHttpHeaders = new Dictionary<string, string>() { - { "user_agent", userAgent }, + { "User-Agent", userAgent }, } }, ExtractChapters = false, @@ -44,7 +44,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing var extraArg = encoder.GetExtraArguments(req); - Assert.Contains(userAgent, extraArg, StringComparison.InvariantCulture); + Assert.Contains($"-user_agent \"{userAgent}\"", extraArg, StringComparison.InvariantCulture); } } } diff --git a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs index 2a62ab74c..a6f416414 100644 --- a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs @@ -141,7 +141,7 @@ namespace Jellyfin.Model.Tests.Entities public void SetProviderId_Null_Remove() { var provider = new ProviderIdsExtensionsTestsObject(); - provider.SetProviderId(MetadataProvider.Imdb, null!); + Assert.Throws<ArgumentNullException>(() => provider.SetProviderId(MetadataProvider.Imdb, null!)); Assert.Empty(provider.ProviderIds); } @@ -150,8 +150,8 @@ namespace Jellyfin.Model.Tests.Entities { var provider = new ProviderIdsExtensionsTestsObject(); provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; - provider.SetProviderId(MetadataProvider.Imdb, string.Empty); - Assert.Empty(provider.ProviderIds); + Assert.Throws<ArgumentException>(() => provider.SetProviderId(MetadataProvider.Imdb, string.Empty)); + Assert.Single(provider.ProviderIds); } [Fact] @@ -182,10 +182,20 @@ namespace Jellyfin.Model.Tests.Entities ProviderIds = null! }; - nullProvider.SetProviderId(MetadataProvider.Imdb, string.Empty); + Assert.Throws<ArgumentException>(() => nullProvider.SetProviderId(MetadataProvider.Imdb, string.Empty)); Assert.Null(nullProvider.ProviderIds); } + [Fact] + public void RemoveProviderId_Null_Remove() + { + var provider = new ProviderIdsExtensionsTestsObject(); + + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; + provider.RemoveProviderId(MetadataProvider.Imdb); + Assert.Empty(provider.ProviderIds); + } + private sealed class ProviderIdsExtensionsTestsObject : IHasProviderIds { public static readonly ProviderIdsExtensionsTestsObject Empty = new ProviderIdsExtensionsTestsObject(); diff --git a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs index ba602b5d2..0b8b1f644 100644 --- a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs @@ -104,6 +104,7 @@ public class ExternalPathParserTests [InlineData(".en.cc.title", "title", "eng", false, false, true)] [InlineData(".hi.en.title", "title", "eng", false, false, true)] [InlineData(".en.hi.title", "title", "eng", false, false, true)] + [InlineData(".Subs for Chinese Audio.eng", "Subs for Chinese Audio", "eng", false, false, false)] public void ParseFile_ExtraTokens_ParseToValues(string tokens, string? title, string? language, bool isDefault = false, bool isForced = false, bool isHearingImpaired = false) { var path = "My.Video" + tokens + ".srt"; diff --git a/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs b/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs new file mode 100644 index 000000000..2d4b5b730 --- /dev/null +++ b/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs @@ -0,0 +1,31 @@ +using Emby.Naming.TV; +using MediaBrowser.Model.Entities; +using Xunit; + +namespace Jellyfin.Naming.Tests.TV; + +public class TvParserHelpersTest +{ + [Theory] + [InlineData("Ended", SeriesStatus.Ended)] + [InlineData("Cancelled", SeriesStatus.Ended)] + [InlineData("Continuing", SeriesStatus.Continuing)] + [InlineData("Returning", SeriesStatus.Continuing)] + [InlineData("Returning Series", SeriesStatus.Continuing)] + [InlineData("Unreleased", SeriesStatus.Unreleased)] + public void SeriesStatusParserTest_Valid(string statusString, SeriesStatus? status) + { + var successful = TvParserHelpers.TryParseSeriesStatus(statusString, out var parsered); + Assert.True(successful); + Assert.Equal(status, parsered); + } + + [Theory] + [InlineData("XXX")] + public void SeriesStatusParserTest_InValid(string statusString) + { + var successful = TvParserHelpers.TryParseSeriesStatus(statusString, out var parsered); + Assert.False(successful); + Assert.Null(parsered); + } +} diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs index be5a401b1..5dd3eb8ab 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -209,7 +209,7 @@ namespace Jellyfin.Providers.Tests.Manager [InlineData(ImageType.Backdrop, 2, false)] [InlineData(ImageType.Primary, 1, true)] [InlineData(ImageType.Backdrop, 2, true)] - public async void RefreshImages_PopulatedItemPopulatedProviderDynamic_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh) + public async Task RefreshImages_PopulatedItemPopulatedProviderDynamic_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh) { var item = GetItemWithImages(imageType, imageCount, false); @@ -261,7 +261,7 @@ namespace Jellyfin.Providers.Tests.Manager [InlineData(ImageType.Backdrop, 2, true, MediaProtocol.File)] [InlineData(ImageType.Primary, 1, false, MediaProtocol.File)] [InlineData(ImageType.Backdrop, 2, false, MediaProtocol.File)] - public async void RefreshImages_EmptyItemPopulatedProviderDynamic_AddsImages(ImageType imageType, int imageCount, bool responseHasPath, MediaProtocol protocol) + public async Task RefreshImages_EmptyItemPopulatedProviderDynamic_AddsImages(ImageType imageType, int imageCount, bool responseHasPath, MediaProtocol protocol) { // Has to exist for querying DateModified time on file, results stored but not checked so not populating BaseItem.FileSystem = Mock.Of<IFileSystem>(); @@ -311,7 +311,7 @@ namespace Jellyfin.Providers.Tests.Manager [InlineData(ImageType.Primary, 1, true)] [InlineData(ImageType.Backdrop, 1, true)] [InlineData(ImageType.Backdrop, 2, true)] - public async void RefreshImages_PopulatedItemPopulatedProviderRemote_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh) + public async Task RefreshImages_PopulatedItemPopulatedProviderRemote_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh) { var item = GetItemWithImages(imageType, imageCount, false); @@ -366,7 +366,7 @@ namespace Jellyfin.Providers.Tests.Manager [InlineData(ImageType.Backdrop, 0, false)] // empty item, no cache to check [InlineData(ImageType.Backdrop, 1, false)] // populated item, cached so no download [InlineData(ImageType.Backdrop, 1, true)] // populated item, forced to download - public async void RefreshImages_NonStubItemPopulatedProviderRemote_DownloadsIfNecessary(ImageType imageType, int initialImageCount, bool fullRefresh) + public async Task RefreshImages_NonStubItemPopulatedProviderRemote_DownloadsIfNecessary(ImageType imageType, int initialImageCount, bool fullRefresh) { var targetImageCount = 1; @@ -429,7 +429,7 @@ namespace Jellyfin.Providers.Tests.Manager [Theory] [MemberData(nameof(GetImageTypesWithCount))] - public async void RefreshImages_EmptyItemPopulatedProviderRemoteExtras_LimitsImages(ImageType imageType, int imageCount) + public async Task RefreshImages_EmptyItemPopulatedProviderRemoteExtras_LimitsImages(ImageType imageType, int imageCount) { var item = new Video(); @@ -473,7 +473,7 @@ namespace Jellyfin.Providers.Tests.Manager [Theory] [MemberData(nameof(GetImageTypesWithCount))] - public async void RefreshImages_PopulatedItemEmptyProviderRemoteFullRefresh_DoesntClearImages(ImageType imageType, int imageCount) + public async Task RefreshImages_PopulatedItemEmptyProviderRemoteFullRefresh_DoesntClearImages(ImageType imageType, int imageCount) { var item = GetItemWithImages(imageType, imageCount, false); @@ -501,7 +501,7 @@ namespace Jellyfin.Providers.Tests.Manager [InlineData(9, false)] [InlineData(10, true)] [InlineData(null, true)] - public async void RefreshImages_ProviderRemote_FiltersByWidth(int? remoteImageWidth, bool expectedToUpdate) + public async Task RefreshImages_ProviderRemote_FiltersByWidth(int? remoteImageWidth, bool expectedToUpdate) { var imageType = ImageType.Primary; @@ -575,18 +575,22 @@ namespace Jellyfin.Providers.Tests.Manager // Has to exist for querying DateModified time on file, results stored but not checked so not populating BaseItem.FileSystem ??= Mock.Of<IFileSystem>(); - var item = new Video(); + var item = new Mock<Video> + { + CallBase = true + }; + item.Setup(m => m.IsSaveLocalMetadataEnabled()).Returns(false); var path = validPaths ? _testDataImagePath.Format : "invalid path {0}"; for (int i = 0; i < count; i++) { - item.SetImagePath(type, i, new FileSystemMetadata + item.Object.SetImagePath(type, i, new FileSystemMetadata { FullName = string.Format(CultureInfo.InvariantCulture, path, i), }); } - return item; + return item.Object; } private static ILocalImageProvider GetImageProvider(ImageType type, int count, bool validPaths) diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs index ec4df9981..cedcaf9c0 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Movies; @@ -19,7 +20,7 @@ namespace Jellyfin.Providers.Tests.Manager [InlineData(true, true)] public void MergeBaseItemData_MergeMetadataSettings_MergesWhenSet(bool mergeMetadataSettings, bool defaultDate) { - var newLocked = new[] { MetadataField.Cast }; + var newLocked = new[] { MetadataField.Genres, MetadataField.Cast }; var newString = "new"; var newDate = DateTime.Now; @@ -77,7 +78,7 @@ namespace Jellyfin.Providers.Tests.Manager [Theory] [InlineData("Name", MetadataField.Name, false)] - [InlineData("OriginalTitle", null, false)] + [InlineData("OriginalTitle", null)] [InlineData("OfficialRating", MetadataField.OfficialRating)] [InlineData("CustomRating")] [InlineData("Tagline")] diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 6fccce049..cced2b1e2 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -585,15 +585,17 @@ namespace Jellyfin.Providers.Tests.Manager IEnumerable<IMetadataService>? metadataServices = null, IEnumerable<IMetadataProvider>? metadataProviders = null, IEnumerable<IMetadataSaver>? metadataSavers = null, - IEnumerable<IExternalId>? externalIds = null) + IEnumerable<IExternalId>? externalIds = null, + IEnumerable<IExternalUrlProvider>? externalUrlProviders = null) { imageProviders ??= Array.Empty<IImageProvider>(); metadataServices ??= Array.Empty<IMetadataService>(); metadataProviders ??= Array.Empty<IMetadataProvider>(); metadataSavers ??= Array.Empty<IMetadataSaver>(); externalIds ??= Array.Empty<IExternalId>(); + externalUrlProviders ??= Array.Empty<IExternalUrlProvider>(); - providerManager.AddParts(imageProviders, metadataServices, metadataProviders, metadataSavers, externalIds); + providerManager.AddParts(imageProviders, metadataServices, metadataProviders, metadataSavers, externalIds, externalUrlProviders); } /// <summary> diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs index d5f6873a2..290cb817a 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs @@ -64,7 +64,7 @@ public class AudioResolverTests [InlineData("My.Video.mp3", false, true)] [InlineData("My.Video.srt", true, false)] [InlineData("My.Video.mp3", true, true)] - public async void GetExternalStreams_MixedFilenames_PicksAudio(string file, bool metadataDirectory, bool matches) + public async Task GetExternalStreams_MixedFilenames_PicksAudio(string file, bool metadataDirectory, bool matches) { BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs index 85963e5de..c0b41ba43 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs @@ -37,7 +37,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo } [Fact] - public async void GetImage_NoStreams_ReturnsNoImage() + public async Task GetImage_NoStreams_ReturnsNoImage() { var input = new Movie(); @@ -55,7 +55,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo [InlineData("clearlogo.png", null, 1, ImageType.Logo, ImageFormat.Png)] // extract extension from name [InlineData("backdrop", "image/bmp", 2, ImageType.Backdrop, ImageFormat.Bmp)] // extract extension from mimetype [InlineData("poster", null, 3, ImageType.Primary, ImageFormat.Jpg)] // default extension to jpg - public async void GetImage_Attachment_ReturnsCorrectSelection(string filename, string? mimetype, int targetIndex, ImageType type, ImageFormat? expectedFormat) + public async Task GetImage_Attachment_ReturnsCorrectSelection(string filename, string? mimetype, int targetIndex, ImageType type, ImageFormat? expectedFormat) { var attachments = new List<MediaAttachment>(); string pathPrefix = "path"; @@ -103,7 +103,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo [InlineData(null, "mjpeg", 1, ImageType.Primary, ImageFormat.Jpg)] [InlineData(null, "png", 1, ImageType.Primary, ImageFormat.Png)] [InlineData(null, "webp", 1, ImageType.Primary, ImageFormat.Webp)] - public async void GetImage_Embedded_ReturnsCorrectSelection(string? label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat) + public async Task GetImage_Embedded_ReturnsCorrectSelection(string? label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat) { var streams = new List<MediaStream>(); for (int i = 1; i <= targetIndex; i++) diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index 58b67ae55..db427308c 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -182,7 +182,7 @@ public class MediaInfoResolverTests [Theory] [InlineData("https://url.com/My.Video.mkv")] [InlineData(VideoDirectoryPath)] // valid but no files found for this test - public async void GetExternalStreams_BadPaths_ReturnsNoSubtitles(string path) + public async Task GetExternalStreams_BadPaths_ReturnsNoSubtitles(string path) { // need a media source manager capable of returning something other than file protocol var mediaSourceManager = new Mock<IMediaSourceManager>(); @@ -285,7 +285,7 @@ public class MediaInfoResolverTests [Theory] [MemberData(nameof(GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly_Data))] - public async void GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly(string file, MediaStream[] inputStreams, MediaStream[] expectedStreams) + public async Task GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly(string file, MediaStream[] inputStreams, MediaStream[] expectedStreams) { BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); @@ -335,7 +335,7 @@ public class MediaInfoResolverTests [InlineData(1, 2)] [InlineData(2, 1)] [InlineData(2, 2)] - public async void GetExternalStreams_StreamIndex_HandlesFilesAndContainers(int fileCount, int streamCount) + public async Task GetExternalStreams_StreamIndex_HandlesFilesAndContainers(int fileCount, int streamCount) { BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs index 8077bd791..e0d365927 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs @@ -64,7 +64,7 @@ public class SubtitleResolverTests [InlineData("My.Video.mp3", false, false)] [InlineData("My.Video.srt", true, true)] [InlineData("My.Video.mp3", true, false)] - public async void GetExternalStreams_MixedFilenames_PicksSubtitles(string file, bool metadataDirectory, bool matches) + public async Task GetExternalStreams_MixedFilenames_PicksSubtitles(string file, bool metadataDirectory, bool matches) { BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>(); diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs index 7ea6f7d9c..028f6feba 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs @@ -34,7 +34,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo [Theory] [MemberData(nameof(GetImage_UnsupportedInput_ReturnsNoImage_TestData))] - public async void GetImage_UnsupportedInput_ReturnsNoImage(Video input) + public async Task GetImage_UnsupportedInput_ReturnsNoImage(Video input) { var mediaSourceManager = GetMediaSourceManager(input, null, new List<MediaStream>()); var videoImageProvider = new VideoImageProvider(mediaSourceManager, Mock.Of<IMediaEncoder>(), new NullLogger<VideoImageProvider>()); @@ -47,7 +47,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo [Theory] [InlineData(1, 1)] // default not first stream [InlineData(5, 0)] // default out of valid range - public async void GetImage_DefaultVideoStreams_ReturnsCorrectStreamImage(int defaultIndex, int targetIndex) + public async Task GetImage_DefaultVideoStreams_ReturnsCorrectStreamImage(int defaultIndex, int targetIndex) { var input = new Movie { DefaultVideoStreamIndex = defaultIndex }; @@ -80,7 +80,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo [Theory] [InlineData(null, 10)] // default time [InlineData(500, 50)] // calculated time - public async void GetImage_TimeSpan_SelectsCorrectTime(int? runTimeSeconds, long expectedSeconds) + public async Task GetImage_TimeSpan_SelectsCorrectTime(int? runTimeSeconds, long expectedSeconds) { MediaStream targetStream = new() { Type = MediaStreamType.Video, Index = 0 }; var input = new Movie diff --git a/tests/Jellyfin.Server.Implementations.Tests/Sorting/PremiereDateComparerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Sorting/PremiereDateComparerTests.cs new file mode 100644 index 000000000..9dfacb2bf --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Sorting/PremiereDateComparerTests.cs @@ -0,0 +1,76 @@ +using System; +using Emby.Server.Implementations.Sorting; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Sorting +{ + public class PremiereDateComparerTests + { + private readonly PremiereDateComparer _cmp = new PremiereDateComparer(); + + [Theory] + [ClassData(typeof(PremiereDateTestData))] + public void PremiereDateCompareTest(BaseItem x, BaseItem y, int expected) + { + Assert.Equal(expected, _cmp.Compare(x, y)); + Assert.Equal(-expected, _cmp.Compare(y, x)); + } + + private sealed class PremiereDateTestData : TheoryData<BaseItem, BaseItem, int> + { + public PremiereDateTestData() + { + // Happy case - Both have premier date + // Expected: x listed first + Add( + new Movie { PremiereDate = new DateTime(2018, 1, 1) }, + new Movie { PremiereDate = new DateTime(2018, 1, 3) }, + -1); + + // Both have premiere date, but y has invalid date + // Expected: y listed first + Add( + new Movie { PremiereDate = new DateTime(2019, 1, 1) }, + new Movie { PremiereDate = new DateTime(03, 1, 1) }, + 1); + + // Only x has premiere date, with earlier year than y + // Expected: x listed first + Add( + new Movie { PremiereDate = new DateTime(2020, 1, 1) }, + new Movie { ProductionYear = 2021 }, + -1); + + // Only x has premiere date, with same year as y + // Expected: y listed first + Add( + new Movie { PremiereDate = new DateTime(2022, 1, 2) }, + new Movie { ProductionYear = 2022 }, + 1); + + // Only x has a premiere date, with later year than y + // Expected: y listed first + Add( + new Movie { PremiereDate = new DateTime(2024, 3, 1) }, + new Movie { ProductionYear = 2023 }, + 1); + + // Only x has a premiere date, y has an invalid year + // Expected: y listed first + Add( + new Movie { PremiereDate = new DateTime(2025, 1, 1) }, + new Movie { ProductionYear = 0 }, + 1); + + // Only x has a premiere date, y has neither date nor year + // Expected: y listed first + Add( + new Movie { PremiereDate = new DateTime(2026, 1, 1) }, + new Movie(), + 1); + } + } + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs new file mode 100644 index 000000000..bf3bfdad4 --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using Jellyfin.Api.Models.LibraryStructureDto; +using Jellyfin.Extensions.Json; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.Entities; +using Xunit; +using Xunit.Priority; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +[TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)] +public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private static string? _accessToken; + + public LibraryStructureControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + [Priority(-1)] + public async Task Post_NewVirtualFolder_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var body = new AddVirtualFolderDto() + { + LibraryOptions = new LibraryOptions() + { + Enabled = false + } + }; + + using var response = await client.PostAsJsonAsync("Library/VirtualFolders?name=test&refreshLibrary=true", body, _jsonOptions); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + [Priority(0)] + public async Task UpdateLibraryOptions_Invalid_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + var body = new UpdateLibraryOptionsDto() + { + Id = Guid.NewGuid(), + LibraryOptions = new LibraryOptions() + }; + + using var response = await client.PostAsJsonAsync("Library/VirtualFolders/LibraryOptions", body, _jsonOptions); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + [Priority(0)] + public async Task UpdateLibraryOptions_Valid_Success() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + using var response = await client.GetAsync("Library/VirtualFolders"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var library = await response.Content.ReadFromJsonAsAsyncEnumerable<VirtualFolderInfo>(_jsonOptions) + .FirstOrDefaultAsync(x => string.Equals(x?.Name, "test", StringComparison.Ordinal)); + Assert.NotNull(library); + + var options = library.LibraryOptions; + Assert.NotNull(options); + Assert.False(options.Enabled); + options.Enabled = true; + + var body = new UpdateLibraryOptionsDto() + { + Id = Guid.Parse(library.ItemId), + LibraryOptions = options + }; + + using var response2 = await client.PostAsJsonAsync("Library/VirtualFolders/LibraryOptions", body, _jsonOptions); + Assert.Equal(HttpStatusCode.NoContent, response2.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task DeleteLibrary_Invalid_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + using var response = await client.DeleteAsync("Library/VirtualFolders?name=doesntExist"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + [Priority(1)] + public async Task DeleteLibrary_Valid_Success() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + using var response = await client.DeleteAsync("Library/VirtualFolders?name=test&refreshLibrary=true"); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } +} diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs index 8019e0ab3..2f05c4ea2 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs @@ -47,6 +47,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Location var movie = new Movie() { Path = "/media/movies/Avengers Endgame", VideoType = VideoType.Dvd }; var path1 = "/media/movies/Avengers Endgame/Avengers Endgame.nfo"; var path2 = "/media/movies/Avengers Endgame/VIDEO_TS/VIDEO_TS.nfo"; + var path3 = "/media/movies/Avengers Endgame/movie.nfo"; // uses ContainingFolderPath which uses Operating system specific paths if (OperatingSystem.IsWindows()) @@ -54,12 +55,14 @@ namespace Jellyfin.XbmcMetadata.Tests.Location movie.Path = movie.Path.Replace('/', '\\'); path1 = path1.Replace('/', '\\'); path2 = path2.Replace('/', '\\'); + path3 = path3.Replace('/', '\\'); } var paths = MovieNfoSaver.GetMovieSavePaths(new ItemInfo(movie)).ToArray(); - Assert.Equal(2, paths.Length); + Assert.Equal(3, paths.Length); Assert.Contains(path1, paths); Assert.Contains(path2, paths); + Assert.Contains(path3, paths); } } } |
