diff options
81 files changed, 1085 insertions, 1508 deletions
diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 70bcd4973..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: 2 -updates: -- package-ecosystem: nuget - directory: "/" - schedule: - interval: weekly - time: '12:00' - open-pull-requests-limit: 10 - -- package-ecosystem: github-actions - directory: '/' - schedule: - interval: weekly - time: '12:00' - open-pull-requests-limit: 10 diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..5ca683876 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>jellyfin/.github//renovate-presets/dotnet" + ] +} diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 20294843d..01cd41a08 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -14,7 +14,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Apply label - uses: eps1lon/actions-label-merge-conflict@v2.0.1 + uses: eps1lon/actions-label-merge-conflict@b8bf8341285ec9a4567d4318ba474fee998a6919 # tag=v2.0.1 if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: dirtyLabel: 'merge conflict' @@ -26,7 +26,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Remove from 'Current Release' project - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -35,7 +35,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Release Next' project - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened' continue-on-error: true with: @@ -44,7 +44,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Current Release' project - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -58,7 +58,7 @@ jobs: run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" - name: Move issue to needs triage - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1 continue-on-error: true with: @@ -67,7 +67,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add issue to triage project - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: github.event.issue.pull_request == '' && github.event.action == 'opened' continue-on-error: true with: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 1dbd7fa36..b551bb5a6 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,18 +20,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 - name: Setup .NET Core - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@4d4a70f4a5b2a5a5329f13be4ac933f2c9206ac0 # tag=v3 with: dotnet-version: '6.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@cc7986c02bac29104a72998e67239bb5ee2ee110 # tag=v2 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@cc7986c02bac29104a72998e67239bb5ee2ee110 # tag=v2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@cc7986c02bac29104a72998e67239bb5ee2ee110 # tag=v2 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 23873706d..a29519b29 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -16,20 +16,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 with: token: ${{ secrets.JF_BOT_TOKEN }} comment-id: ${{ github.event.comment.id }} reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 - name: Automatic Rebase - uses: cirrus-actions/rebase@1.7 + uses: cirrus-actions/rebase@6e572f08c244e2f04f9beb85a943eb618218714d # tag=1.7 env: GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -47,14 +47,14 @@ jobs: reactions: eyes - name: Checkout the latest code - uses: actions/checkout@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 - name: Notify as running id: comment_running - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -89,7 +89,7 @@ jobs: exit ${retcode} - name: Notify with result success - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ github.event.comment != null && success() }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -104,7 +104,7 @@ jobs: reactions: hooray - name: Notify with result failure - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ github.event.comment != null && failure() }} with: token: ${{ secrets.JF_BOT_TOKEN }} diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index ceb4e8cdf..7151d329c 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -12,18 +12,18 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET Core - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@4d4a70f4a5b2a5a5329f13be4ac933f2c9206ac0 # tag=v3 with: dotnet-version: '6.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@v3 + uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3 with: name: openapi-head retention-days: 14 @@ -37,17 +37,17 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 with: ref: ${{ github.base_ref }} - name: Setup .NET Core - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@4d4a70f4a5b2a5a5329f13be4ac933f2c9206ac0 # tag=v3 with: dotnet-version: '6.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@v3 + uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # tag=v3 with: name: openapi-base retention-days: 14 @@ -63,12 +63,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@v3 + uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741 # tag=v3 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@v3 + uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741 # tag=v3 with: name: openapi-base path: openapi-base @@ -90,14 +90,14 @@ jobs: body="${body//$'\r'/'%0D'}" echo ::set-output name=body::$body - name: Find difference comment - uses: peter-evans/find-comment@v2 + uses: peter-evans/find-comment@b657a70ff16d17651703a84bee1cb9ad9d2be2ea # tag=v2 id: find-comment with: issue-number: ${{ github.event.pull_request.number }} direction: last body-includes: openapi-diff-workflow-comment - name: Reply or edit difference comment (changed) - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ steps.read-diff.outputs.body != '' }} with: issue-number: ${{ github.event.pull_request.number }} @@ -112,7 +112,7 @@ jobs: </details> - name: Edit difference comment (unchanged) - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }} with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/repo-stale.yaml b/.github/workflows/repo-stale.yaml index 2578f82cf..f7a77f02b 100644 --- a/.github/workflows/repo-stale.yaml +++ b/.github/workflows/repo-stale.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@v6 + - uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # tag=v6 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} days-before-stale: 120 diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 8b7e216e4..8db55a6ae 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -22,7 +22,6 @@ using Emby.Drawing; using Emby.Naming.Common; using Emby.Notifications; using Emby.Photos; -using Emby.Server.Implementations.Archiving; using Emby.Server.Implementations.Channels; using Emby.Server.Implementations.Collections; using Emby.Server.Implementations.Configuration; @@ -561,8 +560,6 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<IInstallationManager, InstallationManager>(); - serviceCollection.AddSingleton<IZipClient, ZipClient>(); - serviceCollection.AddSingleton<IServerApplicationHost>(this); serviceCollection.AddSingleton(ApplicationPaths); @@ -1091,15 +1088,7 @@ namespace Emby.Server.Implementations return GetLocalApiUrl(request.Host.Host, request.Scheme, requestPort); } - // Published server ends with a / - if (!string.IsNullOrEmpty(PublishedServerUrl)) - { - // Published server ends with a '/', so we need to remove it. - return PublishedServerUrl.Trim('/'); - } - - string smart = NetManager.GetBindInterface(request, out var port); - return GetLocalApiUrl(smart.Trim('/'), request.Scheme, port); + return GetSmartApiUrl(request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback); } /// <inheritdoc/> diff --git a/Emby.Server.Implementations/Archiving/ZipClient.cs b/Emby.Server.Implementations/Archiving/ZipClient.cs deleted file mode 100644 index 6a3b250d2..000000000 --- a/Emby.Server.Implementations/Archiving/ZipClient.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.IO; -using MediaBrowser.Model.IO; -using SharpCompress.Common; -using SharpCompress.Readers; -using SharpCompress.Readers.GZip; - -namespace Emby.Server.Implementations.Archiving -{ - /// <summary> - /// Class DotNetZipClient. - /// </summary> - public class ZipClient : IZipClient - { - /// <inheritdoc /> - public void ExtractAllFromGz(Stream source, string targetPath, bool overwriteExistingFiles) - { - using var reader = GZipReader.Open(source); - var options = new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = overwriteExistingFiles - }; - - Directory.CreateDirectory(targetPath); - reader.WriteAllToDirectory(targetPath, options); - } - - /// <inheritdoc /> - public void ExtractFirstFileFromGz(Stream source, string targetPath, string defaultFileName) - { - using var reader = GZipReader.Open(source); - if (reader.MoveToNextEntry()) - { - var entry = reader.Entry; - - var filename = entry.Key; - if (string.IsNullOrWhiteSpace(filename)) - { - filename = defaultFileName; - } - - reader.WriteEntryToFile(Path.Combine(targetPath, filename)); - } - } - } -} diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index 2792a4c7c..ff1102a05 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -25,14 +25,13 @@ <ItemGroup> <PackageReference Include="DiscUtils.Udf" Version="0.16.13" /> <PackageReference Include="Jellyfin.XmlTv" Version="10.8.0" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.9" /> - <PackageReference Include="Mono.Nat" Version="3.0.3" /> - <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.2.4" /> - <PackageReference Include="sharpcompress" Version="0.32.2" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.10" /> + <PackageReference Include="Mono.Nat" Version="3.0.4" /> + <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.3.0" /> <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" /> <PackageReference Include="DotNet.Glob" Version="3.1.3" /> </ItemGroup> diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index ee94670eb..cef82ebbc 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -665,11 +665,7 @@ namespace Emby.Server.Implementations.Library if (result?.Items.Count > 0) { var items = result.Items; - foreach (var item in items) - { - ResolverHelper.SetInitialItemValues(item, parent, this, directoryService); - } - + items.RemoveAll(item => !ResolverHelper.SetInitialItemValues(item, parent, this, directoryService)); items.AddRange(ResolveFileList(result.ExtraFiles, directoryService, parent, collectionType, resolvers, libraryOptions)); return items; } diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs index 20a2edb05..609b95772 100644 --- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs +++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs @@ -45,42 +45,42 @@ namespace Emby.Server.Implementations.Library .ThenByDescending(x => x.IsForced && string.Equals(x.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) .ThenByDescending(x => x.IsForced) .ThenByDescending(x => x.IsDefault) + .ThenByDescending(x => preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) .ToList(); MediaStream? stream = null; if (mode == SubtitlePlaybackMode.Default) { - // Prefer embedded metadata over smart logic - stream = sortedStreams.FirstOrDefault(s => s.IsExternal || s.IsForced || s.IsDefault); - - // if the audio language is not understood by the user, load their preferred subs, if there are any - if (stream == null && !preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) - { - stream = sortedStreams.FirstOrDefault(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase)); - } + // Load subtitles according to external, forced and default flags. + stream = sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault); } else if (mode == SubtitlePlaybackMode.Smart) { - // if the audio language is not understood by the user, load their preferred subs, if there are any + // Only attempt to load subtitles if the audio language is not one of the user's preferred subtitle languages. + // If no subtitles of preferred language available, use default behaviour. if (!preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase)) { - stream = streams.FirstOrDefault(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase)) ?? - streams.FirstOrDefault(s => preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase)); + stream = sortedStreams.FirstOrDefault(x => preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) ?? + sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault); + } + else + { + // Respect forced flag. + stream = sortedStreams.FirstOrDefault(x => x.IsForced); } } else if (mode == SubtitlePlaybackMode.Always) { - // always load the most suitable full subtitles - stream = sortedStreams.FirstOrDefault(s => !s.IsForced); + // Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise default behaviour. + stream = sortedStreams.FirstOrDefault(x => !x.IsForced && preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) ?? + sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault); } else if (mode == SubtitlePlaybackMode.OnlyForced) { - // always load the most suitable full subtitles + // Only load subtitles that are flagged forced. stream = sortedStreams.FirstOrDefault(x => x.IsForced); } - // load forced subs if we have found no suitable full subtitles - stream ??= sortedStreams.FirstOrDefault(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase)); return stream?.Index; } diff --git a/Emby.Server.Implementations/Library/ResolverHelper.cs b/Emby.Server.Implementations/Library/ResolverHelper.cs index ac75e5d3a..4100a74a5 100644 --- a/Emby.Server.Implementations/Library/ResolverHelper.cs +++ b/Emby.Server.Implementations/Library/ResolverHelper.cs @@ -20,8 +20,9 @@ namespace Emby.Server.Implementations.Library /// <param name="parent">The parent.</param> /// <param name="libraryManager">The library manager.</param> /// <param name="directoryService">The directory service.</param> + /// <returns>True if initializing was successful.</returns> /// <exception cref="ArgumentException">Item must have a path.</exception> - public static void SetInitialItemValues(BaseItem item, Folder? parent, ILibraryManager libraryManager, IDirectoryService directoryService) + public static bool SetInitialItemValues(BaseItem item, Folder? parent, ILibraryManager libraryManager, IDirectoryService directoryService) { // This version of the below method has no ItemResolveArgs, so we have to require the path already being set if (string.IsNullOrEmpty(item.Path)) @@ -44,12 +45,14 @@ namespace Emby.Server.Implementations.Library var fileInfo = directoryService.GetFile(item.Path); if (fileInfo == null) { - throw new FileNotFoundException("Can't find item path.", item.Path); + return false; } SetDateCreated(item, fileInfo); EnsureName(item, fileInfo); + + return true; } /// <summary> diff --git a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs index 3d6b9f3b6..b2a7abb1b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -38,7 +38,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// </summary> /// <param name="args">The args.</param> /// <returns>`0.</returns> - public override T Resolve(ItemResolveArgs args) + protected override T Resolve(ItemResolveArgs args) { return ResolveVideo<T>(args, false); } diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 8f224f547..6fc200e3b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -8,15 +8,16 @@ using System.Linq; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers.Books { - public class BookResolver : MediaBrowser.Controller.Resolvers.ItemResolver<Book> + public class BookResolver : ItemResolver<Book> { private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" }; - public override Book Resolve(ItemResolveArgs args) + protected override Book Resolve(ItemResolveArgs args) { var collectionType = args.GetCollectionType(); diff --git a/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs b/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs index f109a5e9a..079962282 100644 --- a/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/GenericFolderResolver.cs @@ -2,6 +2,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Resolvers; namespace Emby.Server.Implementations.Library.Resolvers { diff --git a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs b/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs deleted file mode 100644 index 3f29ab191..000000000 --- a/Emby.Server.Implementations/Library/Resolvers/ItemResolver.cs +++ /dev/null @@ -1,58 +0,0 @@ -#nullable disable - -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Resolvers; - -namespace Emby.Server.Implementations.Library.Resolvers -{ - /// <summary> - /// Class ItemResolver. - /// </summary> - /// <typeparam name="T">The type of BaseItem.</typeparam> - public abstract class ItemResolver<T> : IItemResolver - where T : BaseItem, new() - { - /// <summary> - /// Gets the priority. - /// </summary> - /// <value>The priority.</value> - public virtual ResolverPriority Priority => ResolverPriority.First; - - /// <summary> - /// Resolves the specified args. - /// </summary> - /// <param name="args">The args.</param> - /// <returns>`0.</returns> - protected virtual T Resolve(ItemResolveArgs args) - { - return null; - } - - /// <summary> - /// Sets initial values on the newly resolved item. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="args">The args.</param> - protected virtual void SetInitialItemValues(T item, ItemResolveArgs args) - { - } - - /// <summary> - /// Resolves the path. - /// </summary> - /// <param name="args">The args.</param> - /// <returns>BaseItem.</returns> - BaseItem IItemResolver.ResolvePath(ItemResolveArgs args) - { - var item = Resolve(args); - - if (item != null) - { - SetInitialItemValues(item, args); - } - - return item; - } - } -} diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index b2f388a66..8f9e5f01b 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -80,7 +80,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// </summary> /// <param name="args">The args.</param> /// <returns>Video.</returns> - public override Video Resolve(ItemResolveArgs args) + protected override Video Resolve(ItemResolveArgs args) { var collectionType = args.GetCollectionType(); diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs index af4abfb80..e11fb262e 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs @@ -12,6 +12,7 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Resolvers; using MediaBrowser.Model.Entities; namespace Emby.Server.Implementations.Library.Resolvers diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs index bfa73af2f..9ba079edf 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs @@ -30,7 +30,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV /// </summary> /// <param name="args">The args.</param> /// <returns>Episode.</returns> - public override Episode Resolve(ItemResolveArgs args) + protected override Episode Resolve(ItemResolveArgs args) { var parent = args.Parent; diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index 4da677636..74321a256 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -2219,6 +2219,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV continue; } + // Skip ShowId without SubKey from duplicate removal actions - https://github.com/jellyfin/jellyfin/issues/5856 + if (group.Key.EndsWith("0000", StringComparison.Ordinal)) + { + continue; + } + HandleDuplicateShowIds(groupTimers); } } diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs index bd1cd1e1d..7570a2bcf 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -6,9 +6,9 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.IO.Compression; using System.Linq; using System.Net.Http; -using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions; @@ -33,20 +33,17 @@ namespace Emby.Server.Implementations.LiveTv.Listings private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger<XmlTvListingsProvider> _logger; private readonly IFileSystem _fileSystem; - private readonly IZipClient _zipClient; public XmlTvListingsProvider( IServerConfigurationManager config, IHttpClientFactory httpClientFactory, ILogger<XmlTvListingsProvider> logger, - IFileSystem fileSystem, - IZipClient zipClient) + IFileSystem fileSystem) { _config = config; _httpClientFactory = httpClientFactory; _logger = logger; _fileSystem = fileSystem; - _zipClient = zipClient; } public string Name => "XmlTV"; @@ -67,16 +64,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings { _logger.LogInformation("xmltv path: {Path}", info.Path); - if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) - { - return UnzipIfNeeded(info.Path, info.Path); - } - string cacheFilename = info.Id + ".xml"; string cacheFile = Path.Combine(_config.ApplicationPaths.CachePath, "xmltv", cacheFilename); + if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge)) { - return UnzipIfNeeded(info.Path, cacheFile); + return cacheFile; } // Must check if file exists as parent directory may not exist. @@ -84,93 +77,48 @@ namespace Emby.Server.Implementations.LiveTv.Listings { File.Delete(cacheFile); } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); + } - _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); - - Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)); + if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path); - using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (var fileStream = new FileStream(cacheFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.CopyToBufferSize, FileOptions.Asynchronous)) + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); + } + else { - await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); + await using var stream = AsyncFile.OpenRead(info.Path); + return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false); } - - return UnzipIfNeeded(info.Path, cacheFile); } - private string UnzipIfNeeded(ReadOnlySpan<char> originalUrl, string file) + private async Task<string> UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken) { - ReadOnlySpan<char> ext = Path.GetExtension(originalUrl.LeftPart('?')); + await using var fileStream = new FileStream(file, FileMode.CreateNew, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); - if (ext.Equals(".gz", StringComparison.OrdinalIgnoreCase)) + if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCase)) { try { - string tempFolder = ExtractGz(file); - return FindXmlFile(tempFolder); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error extracting from gz file {File}", file); - } - - try - { - string tempFolder = ExtractFirstFileFromGz(file); - return FindXmlFile(tempFolder); + using var reader = new GZipStream(stream, CompressionMode.Decompress); + await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { - _logger.LogError(ex, "Error extracting from zip file {File}", file); + _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl); } } - - return file; - } - - private string ExtractFirstFileFromGz(string file) - { - using (var stream = File.OpenRead(file)) - { - string tempFolder = GetTempFolderPath(stream); - Directory.CreateDirectory(tempFolder); - - _zipClient.ExtractFirstFileFromGz(stream, tempFolder, "data.xml"); - - return tempFolder; - } - } - - private string ExtractGz(string file) - { - using (var stream = File.OpenRead(file)) + else { - string tempFolder = GetTempFolderPath(stream); - Directory.CreateDirectory(tempFolder); - - _zipClient.ExtractAllFromGz(stream, tempFolder, true); - - return tempFolder; + await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } - } - private string GetTempFolderPath(Stream stream) - { -#pragma warning disable CA5351 - using var md5 = MD5.Create(); -#pragma warning restore CA5351 - var checksum = Convert.ToHexString(md5.ComputeHash(stream)); - stream.Position = 0; - return Path.Combine(_config.ApplicationPaths.TempDirectory, checksum); - } - - private string FindXmlFile(string directory) - { - return _fileSystem.GetFiles(directory, true) - .Where(i => string.Equals(i.Extension, ".xml", StringComparison.OrdinalIgnoreCase)) - .Select(i => i.FullName) - .FirstOrDefault(); + return file; } public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken) @@ -213,16 +161,16 @@ namespace Emby.Server.Implementations.LiveTv.Listings IsMovie = program.Categories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), IsNews = program.Categories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), IsSports = program.Categories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)), - ImageUrl = program.Icon != null && !string.IsNullOrEmpty(program.Icon.Source) ? program.Icon.Source : null, - HasImage = program.Icon != null && !string.IsNullOrEmpty(program.Icon.Source), - OfficialRating = program.Rating != null && !string.IsNullOrEmpty(program.Rating.Value) ? program.Rating.Value : null, + ImageUrl = string.IsNullOrEmpty(program.Icon?.Source) ? null : program.Icon.Source, + HasImage = !string.IsNullOrEmpty(program.Icon?.Source), + OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value, CommunityRating = program.StarRating, SeriesId = program.Episode == null ? null : program.Title.GetMD5().ToString("N", CultureInfo.InvariantCulture) }; if (string.IsNullOrWhiteSpace(program.ProgramId)) { - string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty) /*+ (p.IceTvEpisodeNumber ?? string.Empty)*/; + string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty); if (programInfo.SeasonNumber.HasValue) { diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 9dc2fe799..ada3c7730 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -97,7 +97,7 @@ "TasksChannelsCategory": "قنوات الإنترنت", "TasksLibraryCategory": "مكتبة", "TasksMaintenanceCategory": "صيانة", - "TaskRefreshLibraryDescription": "يفصح مكتبة الوسائط الخاصة بك بحثًا عن ملفات جديدة، ومن ثم يتحدث البيانات الوصفية.", + "TaskRefreshLibraryDescription": "يفحص مكتبة الوسائط الخاصة بك باحثا عن ملفات جديدة، ومن ثم يتحدث البيانات الوصفية.", "TaskRefreshLibrary": "افحص مكتبة الوسائط", "TaskRefreshChapterImagesDescription": "يُنشئ صور مصغرة لمقاطع الفيديو التي تحتوي على فصول.", "TaskRefreshChapterImages": "استخراج صور الفصل", diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 943fc651f..08db5a30e 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimalizovat databázi", "TaskKeyframeExtractorDescription": "Vytahuje klíčové snímky ze souborů videa za účelem vytváření přesnějších seznamů přehrávání HLS. Tento úkol může trvat velmi dlouho.", "TaskKeyframeExtractor": "Vytahovač klíčových snímků", - "External": "Externí" + "External": "Externí", + "HearingImpaired": "Sluchově postižení" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 9c278db4d..e1c3e9de1 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Datenbank optimieren", "TaskKeyframeExtractorDescription": "Extrahiere Keyframes aus Videodateien, um präzisere HLS-Playlisten zu erzeugen. Dieser Vorgang kann sehr lange dauern.", "TaskKeyframeExtractor": "Keyframe Extraktor", - "External": "Extern" + "External": "Extern", + "HearingImpaired": "Hörgeschädigt" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 9e216a166..8e9287af4 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Βελτιστοποίηση βάσης δεδομένων", "TaskKeyframeExtractorDescription": "Εξάγει καρέ από αρχεία βίντεο για να δημιουργήσει πιο ακριβείς λίστες αναπαραγωγής HLS. Αυτή η διεργασία μπορεί να πάρει χρόνο.", "TaskKeyframeExtractor": "Εξαγωγέας βασικών καρέ βίντεο", - "External": "Εξωτερικό" + "External": "Εξωτερικό", + "HearingImpaired": "Με προβλήματα ακοής" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 862410c54..243688388 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimise database", "TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.", "TaskKeyframeExtractor": "Keyframe Extractor", - "External": "External" + "External": "External", + "HearingImpaired": "Hearing Impaired" } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 1289172ba..8ad9e8c71 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimización de base de datos", "External": "Externo", "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reprodución HLS más precisas. Esta tarea puede durar mucho tiempo.", - "TaskKeyframeExtractor": "Extractor de Fotogramas Clave" + "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", + "HearingImpaired": "Personas con discapacidad auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json index dfedce7b3..d657ac7b6 100644 --- a/Emby.Server.Implementations/Localization/Core/eu.json +++ b/Emby.Server.Implementations/Localization/Core/eu.json @@ -116,5 +116,12 @@ "CameraImageUploadedFrom": "{0}-tik kamera irudi berri bat igo da", "AuthenticationSucceededWithUserName": "{0} ongi autentifikatu da", "Application": "Aplikazioa", - "AppDeviceValues": "App: {0}, Gailua: {1}" + "AppDeviceValues": "App: {0}, Gailua: {1}", + "HearingImpaired": "Entzunaldia aldatua", + "ProviderValue": "Hornitzailea: {0}", + "TaskKeyframeExtractorDescription": "Bideo fitxategietako fotograma gakoak ateratzen ditu HLS erreprodukzio-zerrenda zehatzagoak sortzeko. Zeregin honek denbora asko iraun dezake.", + "HeaderRecordingGroups": "Grabaketa taldeak", + "Inherit": "Oinordetu", + "TaskOptimizeDatabaseDescription": "Datu-basea trinkotu eta bertatik espazioa askatzen du. Liburutegia eskaneatu ondoren edo datu-basean aldaketak egin ondoren ataza hau exekutatzeak errendimendua hobetu lezake.", + "TaskKeyframeExtractor": "Fotograma gakoen erauzgailua" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index f0cafd1c0..ec72d58dd 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabase": "Optimoi tietokanta", "TaskKeyframeExtractorDescription": "Purkaa videotiedostojen avainkuvat tarkempien HLS-toistolistojen luomiseksi. Tehtävä saattaa kestää huomattavan pitkään.", "TaskKeyframeExtractor": "Avainkuvien purkain", - "External": "Ulkoinen" + "External": "Ulkoinen", + "HearingImpaired": "Kuulorajoitteinen" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 24ca8f861..3ee045d89 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -5,7 +5,7 @@ "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", - "CameraImageUploadedFrom": "Une nouvelle image de caméra a été téléchargée depuis {0}", + "CameraImageUploadedFrom": "Une nouvelle photo a été téléversée depuis {0}", "Channels": "Chaînes", "ChapterNameValue": "Chapitre {0}", "Collections": "Collections", @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimiser la base de données", "TaskKeyframeExtractorDescription": "Extrait les images clés des fichiers vidéo pour créer des listes de lecture HLS plus précises. Cette tâche peut durer très longtemps.", "TaskKeyframeExtractor": "Extracteur d'image clé", - "External": "Externe" + "External": "Externe", + "HearingImpaired": "Malentendants" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 648c878e9..768245a09 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimiser la base de données", "TaskKeyframeExtractorDescription": "Extrait les images clés des fichiers vidéo pour créer des listes de lecture HLS plus précises. Cette tâche peut durer très longtemps.", "TaskKeyframeExtractor": "Extracteur d'image clé", - "External": "Externe" + "External": "Externe", + "HearingImpaired": "Malentendants" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index b433c6f68..76a98aa54 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -47,7 +47,7 @@ "HeaderFavoriteEpisodes": "Episodios Favoritos", "HeaderFavoriteArtists": "Artistas Favoritos", "HeaderFavoriteAlbums": "Álbunes Favoritos", - "HeaderContinueWatching": "Seguir mirando", + "HeaderContinueWatching": "Seguir vendo", "HeaderAlbumArtists": "Artistas do Album", "Genres": "Xéneros", "Forced": "Forzado", @@ -119,5 +119,9 @@ "UserOnlineFromDevice": "{0} está en liña desde {1}", "UserOfflineFromDevice": "{0} desconectouse desde {1}", "TaskOptimizeDatabaseDescription": "Compacta e libera o espazo libre da base de datos. Executar esta tarefa logo de realizar mudanzas que impliquen modificacións da base de datos ou despois de escanear a biblioteca pode traer mellorías de desempeño.", - "TaskOptimizeDatabase": "Optimizar base de datos" + "TaskOptimizeDatabase": "Optimizar base de datos", + "TaskKeyframeExtractorDescription": "Extrae fragmentos do vídeo para crear listas de reprodución HLS máis precisas. Podería levarlle bastante tempo.", + "External": "Externo", + "HearingImpaired": "Problemas de audición", + "TaskKeyframeExtractor": "Extractor de fragmentos" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index c635dab23..694a3d688 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים ומוריד את שטח האחסון שבשימוש. הרצה של פעולה זו לאחר סריקת הספרייה או שינויים אחרים שמשפיעים על מסד הנתונים יכולה לשפר ביצועים.", "TaskKeyframeExtractorDescription": "חלץ תמונות מפתח מקבצי וידאו בכדי ליצור רשימות השמעה מדויקות יותר של HLS. משימה זו עלולה להימשך זמן רב.", "TaskKeyframeExtractor": "מחלץ תמונות מפתח", - "External": "חיצוני" + "External": "חיצוני", + "HearingImpaired": "לקוי שמיעה" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index c7f2f9c85..62d48cebd 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Adatbázis optimalizálása", "TaskKeyframeExtractor": "Kulcskockák kibontása", "TaskKeyframeExtractorDescription": "Kulcskockákat bont ki a videofájlokból, hogy pontosabb HLS lejátszási listákat hozzon létre. Ez a feladat hosszú ideig tarthat.", - "External": "Külső" + "External": "Külső", + "HearingImpaired": "Hallássérült" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index 3e05525c8..695c0f404 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabase": "Optimalkan basis data", "TaskKeyframeExtractorDescription": "Ekstrak bingkai utama dari file video untuk membuat daftar putar HLS yang lebih tepat. Tugas ini dapat berjalan untuk waktu yang lama.", "TaskKeyframeExtractor": "Ekstraktor Bingkai Utama", - "External": "Luar" + "External": "Luar", + "HearingImpaired": "Gangguan Pendengaran" } diff --git a/Emby.Server.Implementations/Localization/Core/jbo.json b/Emby.Server.Implementations/Localization/Core/jbo.json new file mode 100644 index 000000000..1b47bb2f2 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/jbo.json @@ -0,0 +1,7 @@ +{ + "Albums": "lo albuma", + "Artists": "lo larpra", + "Books": "lo cukta", + "HeaderAlbumArtists": "lo albuma larpra", + "Playlists": "lo zgipor" +} diff --git a/Emby.Server.Implementations/Localization/Core/km.json b/Emby.Server.Implementations/Localization/Core/km.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/km.json @@ -0,0 +1 @@ +{} diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 3f22355d6..d7b2bc00c 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Database optimaliseren", "TaskKeyframeExtractorDescription": "Haalt keyframes uit videobestanden om preciezere HLS afspeellijsten te maken. Dit kan lang duren.", "TaskKeyframeExtractor": "Keyframe Extractor", - "External": "Extern" + "External": "Extern", + "HearingImpaired": "Slechthorend" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 38a36a7e0..b9b93b7b6 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Otimizar base de dados", "TaskKeyframeExtractor": "Extrator de quadro-chave", "TaskKeyframeExtractorDescription": "Extrai quadros-chave de arquivos de vídeo para criar listas de reprodução HLS mais precisas. Esta tarefa pode ser executada por um longo tempo.", - "External": "Externo" + "External": "Externo", + "HearingImpaired": "Deficiência Auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index 2766dab06..d1b73a3eb 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -119,5 +119,9 @@ "Forced": "I detyruar", "Default": "Parazgjedhur", "TaskOptimizeDatabaseDescription": "Kompakton bazën e të dhënave dhe shkurton hapësirën e lirë. Drejtimi i kësaj detyre pasi skanoni bibliotekën ose bëni ndryshime të tjera që nënkuptojnë modifikime të bazës së të dhënave mund të përmirësojë performancën.", - "TaskOptimizeDatabase": "Optimizo databazën" + "TaskOptimizeDatabase": "Optimizo databazën", + "TaskKeyframeExtractorDescription": "Nxjerrë kornizat kryesore nga skedarët video për të krijuar lista luajtjeje më të sakta HLS. Ky veprim mund të dojë një kohë të gjatë për tu kompletuar.", + "TaskKeyframeExtractor": "Nxjerrës i kornizës kryesore", + "External": "Jashtem", + "HearingImpaired": "Dëgjimi i dëmtuar" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 3e0fd11c8..92ce616f2 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabaseDescription": "Стискає базу даних та збільшує вільний простір. Виконання цього завдання після сканування медіатеки або внесення інших змін, які передбачають модифікацію бази даних може покращити продуктивність.", "TaskKeyframeExtractorDescription": "Витягує ключові кадри з відеофайлів для створення більш точних списків відтворення HLS. Це завдання може виконуватися протягом тривалого часу.", "TaskKeyframeExtractor": "Екстрактор ключових кадрів", - "External": "Зовнішній" + "External": "Зовнішній", + "HearingImpaired": "З порушеннями слуху" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index a121fc376..ccfbeef0c 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "优化数据库", "TaskKeyframeExtractorDescription": "从视频文件中提取关键帧以创建更准确的HLS播放列表。这项任务可能需要很长时间。", "TaskKeyframeExtractor": "关键帧提取器", - "External": "外部" + "External": "外部", + "HearingImpaired": "听力障碍" } diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs index 0dd4bf803..3a2ba033e 100644 --- a/Jellyfin.Api/Controllers/PlaystateController.cs +++ b/Jellyfin.Api/Controllers/PlaystateController.cs @@ -274,7 +274,7 @@ namespace Jellyfin.Api.Controllers }; playbackProgressInfo.PlayMethod = ValidatePlayMethod(playbackProgressInfo.PlayMethod, playbackProgressInfo.PlaySessionId); - playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);; + playbackProgressInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); await _sessionManager.OnPlaybackProgress(playbackProgressInfo).ConfigureAwait(false); return NoContent(); } @@ -319,7 +319,7 @@ namespace Jellyfin.Api.Controllers await _transcodingJobHelper.KillTranscodingJobs(User.GetDeviceId()!, playbackStopInfo.PlaySessionId, s => true).ConfigureAwait(false); } - playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);; + playbackStopInfo.SessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); await _sessionManager.OnPlaybackStopped(playbackStopInfo).ConfigureAwait(false); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs index 28415555e..31b95162d 100644 --- a/Jellyfin.Api/Controllers/SessionController.cs +++ b/Jellyfin.Api/Controllers/SessionController.cs @@ -182,7 +182,7 @@ namespace Jellyfin.Api.Controllers }; await _sessionManager.SendPlayCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), sessionId, playRequest, CancellationToken.None) @@ -210,7 +210,7 @@ namespace Jellyfin.Api.Controllers [FromQuery] string? controllingUserId) { await _sessionManager.SendPlaystateCommand( - await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), + await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false), sessionId, new PlaystateRequest() { diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 01e13b4fe..d77126a35 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -108,7 +108,7 @@ namespace Jellyfin.Api.Controllers { var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels); - if (!userId.HasValue || userId.Value.Equals(Guid.Empty)) + if (!userId.HasValue || userId.Value.Equals(default)) { userId = User.GetUserId(); } diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 7e64cf645..595c627f8 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -17,7 +17,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="6.0.9" /> + <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="6.0.10" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" /> <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.4.0" /> diff --git a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index cbbeee024..89de998ca 100644 --- a/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -18,8 +18,8 @@ <ItemGroup> <PackageReference Include="BlurHashSharp" Version="1.2.0" /> <PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.2.0" /> - <PackageReference Include="SkiaSharp" Version="2.88.2" /> - <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.2" /> + <PackageReference Include="SkiaSharp" Version="2.88.3" /> + <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="2.88.3" /> <PackageReference Include="SkiaSharp.Svg" Version="1.60.0" /> </ItemGroup> diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index 83b226278..e1f902efc 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -27,13 +27,13 @@ <ItemGroup> <PackageReference Include="System.Linq.Async" Version="6.0.1" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.9" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.9" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.9"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.10" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.10" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.10"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.9"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.10"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index b2d79050b..a5f20d671 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -37,8 +37,8 @@ <PackageReference Include="CommandLineParser" Version="2.9.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.9" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="6.0.9" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.10" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="6.0.10" /> <PackageReference Include="prometheus-net" Version="6.0.0" /> <PackageReference Include="prometheus-net.AspNetCore" Version="6.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="4.1.0" /> diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 41fce67fa..24163f1df 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -775,36 +775,6 @@ namespace MediaBrowser.Controller.Entities return Id.ToString("N", CultureInfo.InvariantCulture); } - private List<Tuple<StringBuilder, bool>> GetSortChunks(string s1) - { - var list = new List<Tuple<StringBuilder, bool>>(); - - int thisMarker = 0; - - while (thisMarker < s1.Length) - { - char thisCh = s1[thisMarker]; - - var thisChunk = new StringBuilder(); - bool isNumeric = char.IsDigit(thisCh); - - while (thisMarker < s1.Length && char.IsDigit(thisCh) == isNumeric) - { - thisChunk.Append(thisCh); - thisMarker++; - - if (thisMarker < s1.Length) - { - thisCh = s1[thisMarker]; - } - } - - list.Add(new Tuple<StringBuilder, bool>(thisChunk, isNumeric)); - } - - return list; - } - public virtual bool CanDelete() { if (SourceType == SourceType.Channel) @@ -951,28 +921,40 @@ namespace MediaBrowser.Controller.Entities return ModifySortChunks(sortable); } - private string ModifySortChunks(string name) + internal static string ModifySortChunks(string name) { - var chunks = GetSortChunks(name); + void AppendChunk(StringBuilder builder, bool isDigitChunk, ReadOnlySpan<char> chunk) + { + if (isDigitChunk && chunk.Length < 10) + { + builder.Append('0', 10 - chunk.Length); + } - var builder = new StringBuilder(); + builder.Append(chunk); + } - foreach (var chunk in chunks) + if (name.Length == 0) { - var chunkBuilder = chunk.Item1; + return string.Empty; + } + + var builder = new StringBuilder(name.Length); - // This chunk is numeric - if (chunk.Item2) + int chunkStart = 0; + bool isDigitChunk = char.IsDigit(name[0]); + for (int i = 0; i < name.Length; i++) + { + var isDigit = char.IsDigit(name[i]); + if (isDigit != isDigitChunk) { - while (chunkBuilder.Length < 10) - { - chunkBuilder.Insert(0, '0'); - } + AppendChunk(builder, isDigitChunk, name.AsSpan(chunkStart, i - chunkStart)); + chunkStart = i; + isDigitChunk = isDigit; } - - builder.Append(chunkBuilder); } + AppendChunk(builder, isDigitChunk, name.AsSpan(chunkStart)); + // logger.LogDebug("ModifySortChunks Start: {0} End: {1}", name, builder.ToString()); return builder.ToString().RemoveDiacritics(); } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index d0362b128..235a86138 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -31,10 +31,13 @@ namespace MediaBrowser.Controller.MediaEncoding private const string VideotoolboxAlias = "vt"; private const string OpenclAlias = "ocl"; private const string CudaAlias = "cu"; + private const string DrmAlias = "dr"; + private const string VulkanAlias = "vk"; private readonly IApplicationPaths _appPaths; private readonly IMediaEncoder _mediaEncoder; private readonly ISubtitleEncoder _subtitleEncoder; private readonly IConfiguration _config; + private readonly Version _minKernelVersionAmdVkFmtModifier = new Version(5, 15); private readonly Version _minKernelVersioni915Hang = new Version(5, 18); private static readonly string[] _videoProfilesH264 = new[] @@ -149,6 +152,14 @@ namespace MediaBrowser.Controller.MediaEncoding && _mediaEncoder.SupportsFilter("hwupload_cuda"); } + private bool IsVulkanFullSupported() + { + return _mediaEncoder.SupportsHwaccel("vulkan") + && _mediaEncoder.SupportsFilter("libplacebo") + && _mediaEncoder.SupportsFilter("scale_vulkan") + && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.OverlayVulkanFrameSync); + } + private bool IsHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { if (state.VideoStream == null @@ -176,6 +187,19 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(state.VideoStream.VideoRangeType, "HLG", StringComparison.OrdinalIgnoreCase)); } + private bool IsVulkanHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) + { + if (state.VideoStream == null) + { + return false; + } + + // libplacebo has partial Dolby Vision to SDR tonemapping support. + return options.EnableTonemapping + && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) + && GetVideoColorBitDepth(state) == 10; + } + private bool IsVaapiVppTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { if (state.VideoStream == null @@ -756,8 +780,13 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (_mediaEncoder.IsVaapiDeviceAmd) { - args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias)); - filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + if (!IsVulkanFullSupported() + || !_mediaEncoder.IsVaapiDeviceSupportVulkanFmtModifier + || Environment.OSVersion.Version < _minKernelVersionAmdVkFmtModifier) + { + args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias)); + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + } } else { @@ -2774,22 +2803,41 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Empty; } - var args = "tonemap_{0}=format={1}:p=bt709:t=bt709:m=bt709"; + var args = string.Empty; + var algorithm = options.TonemappingAlgorithm; - if (hwTonemapSuffix.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(hwTonemapSuffix, "vaapi", StringComparison.OrdinalIgnoreCase)) { - args += ",procamp_vaapi=b={2}:c={3}:extra_hw_frames=16"; + args = "tonemap_vaapi=format={0}:p=bt709:t=bt709:m=bt709,procamp_vaapi=b={1}:c={2}:extra_hw_frames=16"; return string.Format( CultureInfo.InvariantCulture, args, - hwTonemapSuffix, videoFormat ?? "nv12", options.VppTonemappingBrightness, options.VppTonemappingContrast); } + else if (string.Equals(hwTonemapSuffix, "vulkan", StringComparison.OrdinalIgnoreCase)) + { + args = "libplacebo=format={1}:tonemapping={2}:color_primaries=bt709:color_trc=bt709:colorspace=bt709:peak_detect=0:upscaler=none:downscaler=none"; + + if (!string.Equals(options.TonemappingRange, "auto", StringComparison.OrdinalIgnoreCase)) + { + args += ":range={6}"; + } + + if (string.Equals(options.TonemappingAlgorithm, "bt2390", StringComparison.OrdinalIgnoreCase)) + { + algorithm = "bt.2390"; + } + + else if (string.Equals(options.TonemappingAlgorithm, "none", StringComparison.OrdinalIgnoreCase)) + { + algorithm = "clip"; + } + } else { - args += ":tonemap={2}:peak={3}:desat={4}"; + args = "tonemap_{0}=format={1}:p=bt709:t=bt709:m=bt709:tonemap={2}:peak={3}:desat={4}"; if (options.TonemappingParam != 0) { @@ -2807,7 +2855,7 @@ namespace MediaBrowser.Controller.MediaEncoding args, hwTonemapSuffix, videoFormat ?? "nv12", - options.TonemappingAlgorithm, + algorithm, options.TonemappingPeak, options.TonemappingDesat, options.TonemappingParam, @@ -3770,7 +3818,9 @@ namespace MediaBrowser.Controller.MediaEncoding var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); - var isVaapiOclSupported = isLinux && IsVaapiSupported(state) && IsVaapiFullSupported() && IsOpenclFullSupported(); + var isVaapiFullSupported = isLinux && IsVaapiSupported(state) && IsVaapiFullSupported(); + var isVaapiOclSupported = isVaapiFullSupported && IsOpenclFullSupported(); + var isVaapiVkSupported = isVaapiFullSupported && IsVulkanFullSupported(); // legacy vaapi pipeline(copy-back) if ((isSwDecoder && isSwEncoder) @@ -3798,14 +3848,24 @@ namespace MediaBrowser.Controller.MediaEncoding if (_mediaEncoder.IsVaapiDeviceInteliHD) { // Intel iHD path, with extra vpp tonemap and overlay support. - return GetVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder); + return GetIntelVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder); + } + + // prefered vaapi + vulkan filters pipeline + if (_mediaEncoder.IsVaapiDeviceAmd + && isVaapiVkSupported + && _mediaEncoder.IsVaapiDeviceSupportVulkanFmtModifier + && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier) + { + // AMD radeonsi path(Vega/gfx9+, kernel>=5.15), with extra vulkan tonemap and overlay support. + return GetAmdVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder); } - // Intel i965 and Amd radeonsi/r600 path, only featuring scale and deinterlace support. + // Intel i965 and Amd radeonsi/r600 path(Polaris/gfx8-), only featuring scale and deinterlace support. return GetVaapiLimitedVidFiltersPrefered(state, options, vidDecoder, vidEncoder); } - public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetVaapiFullVidFiltersPrefered( + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetIntelVaapiFullVidFiltersPrefered( EncodingJobInfo state, EncodingOptions options, string vidDecoder, @@ -4003,6 +4063,203 @@ namespace MediaBrowser.Controller.MediaEncoding return (mainFilters, subFilters, overlayFilters); } + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetAmdVaapiFullVidFiltersPrefered( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var isVaapiDecoder = vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !isVaapiEncoder; + var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; + + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doVkTonemap = IsVulkanHwTonemapAvailable(state, options); + var doDeintH2645 = doDeintH264 || doDeintHevc; + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + var hasAssSubs = hasSubs + && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doVkTonemap)); + + if (isSwDecoder) + { + // INPUT sw surface(memory) + // sw deint + if (doDeintH2645) + { + var swDeintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(swDeintFilter); + } + + var outFormat = doVkTonemap ? "yuv420p10le" : "nv12"; + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // keep video at memory except vk tonemap, + // since the overhead caused by hwupload >>> using sw filter. + // sw => hw + if (doVkTonemap) + { + mainFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16"); + } + } + else if (isVaapiDecoder) + { + // INPUT vaapi surface(vram) + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi"); + mainFilters.Add(deintFilter); + } + + var outFormat = doVkTonemap ? string.Empty : (hasSubs && isVaInVaOut ? "bgra" : "nv12"); + var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + + // allocate extra pool sizes for overlay_vulkan + if (!string.IsNullOrEmpty(hwScaleFilter) && isVaInVaOut && hasSubs) + { + hwScaleFilter += ":extra_hw_frames=32"; + } + + // hw scale + mainFilters.Add(hwScaleFilter); + } + + if ((isVaapiDecoder && doVkTonemap) || (isVaInVaOut && (doVkTonemap || hasSubs))) + { + // map from vaapi to vulkan via vaapi-vulkan interop (Vega/gfx9+). + mainFilters.Add("hwmap=derive_device=vulkan"); + } + + // vk tonemap + if (doVkTonemap) + { + var outFormat = isVaInVaOut && hasSubs ? "bgra" : "nv12"; + var tonemapFilter = GetHwTonemapFilter(options, "vulkan", outFormat); + mainFilters.Add(tonemapFilter); + } + + if (doVkTonemap && isVaInVaOut && !hasSubs) + { + // OUTPUT vaapi(nv12/bgra) surface(vram) + // reverse-mapping via vaapi-vulkan interop. + mainFilters.Add("hwmap=derive_device=vaapi:reverse=1"); + mainFilters.Add("format=vaapi"); + } + + var memoryOutput = false; + var isUploadForVkTonemap = isSwDecoder && doVkTonemap; + if ((isVaapiDecoder && isSwEncoder) || isUploadForVkTonemap) + { + memoryOutput = true; + + // OUTPUT nv12 surface(memory) + mainFilters.Add("hwdownload"); + mainFilters.Add("format=nv12"); + } + + // OUTPUT nv12 surface(memory) + if (isSwDecoder && isVaapiEncoder) + { + memoryOutput = true; + } + + if (memoryOutput) + { + // text subtitles + if (hasTextSubs) + { + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + } + + if (memoryOutput && isVaapiEncoder) + { + if (!hasGraphicalSubs) + { + mainFilters.Add("hwupload_vaapi"); + } + } + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + if (isVaInVaOut) + { + if (hasSubs) + { + if (hasGraphicalSubs) + { + // scale=s=1280x720,format=bgra,hwupload + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + subFilters.Add("format=bgra"); + } + else if (hasTextSubs) + { + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5); + var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); + subFilters.Add(alphaSrcFilter); + subFilters.Add("format=bgra"); + subFilters.Add(subTextSubtitlesFilter); + } + + subFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16"); + + overlayFilters.Add("overlay_vulkan=eof_action=endall:shortest=1:repeatlast=0"); + + // explicitly sync using libplacebo. + overlayFilters.Add("libplacebo=format=nv12:upscaler=none:downscaler=none"); + + // OUTPUT vaapi(nv12/bgra) surface(vram) + // reverse-mapping via vaapi-vulkan interop. + overlayFilters.Add("hwmap=derive_device=vaapi:reverse=1"); + overlayFilters.Add("format=vaapi"); + } + } + else if (memoryOutput) + { + if (hasGraphicalSubs) + { + var subSwScaleFilter = isSwDecoder + ? GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH) + : GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); + + if (isVaapiEncoder) + { + overlayFilters.Add("hwupload_vaapi"); + } + } + } + + return (mainFilters, subFilters, overlayFilters); + } + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetVaapiLimitedVidFiltersPrefered( EncodingJobInfo state, EncodingOptions options, diff --git a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs index a4869cb67..b1d319d21 100644 --- a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs +++ b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs @@ -28,6 +28,11 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// The overlay_vaapi_framesync. /// </summary> - OverlayVaapiFrameSync = 4 + OverlayVaapiFrameSync = 4, + + /// <summary> + /// The overlay_vulkan_framesync. + /// </summary> + OverlayVulkanFrameSync = 5 } } diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index 69d0bf45c..52c57b906 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -62,6 +62,12 @@ namespace MediaBrowser.Controller.MediaEncoding bool IsVaapiDeviceInteli965 { get; } /// <summary> + /// Gets a value indicating whether the configured Vaapi device supports vulkan drm format modifier. + /// </summary> + /// <value><c>true</c> if the Vaapi device supports vulkan drm format modifier, <c>false</c> otherwise.</value> + bool IsVaapiDeviceSupportVulkanFmtModifier { get; } + + /// <summary> /// Whether given encoder codec is supported. /// </summary> /// <param name="encoder">The encoder.</param> diff --git a/MediaBrowser.Controller/Properties/AssemblyInfo.cs b/MediaBrowser.Controller/Properties/AssemblyInfo.cs index 60e792309..534dec8d2 100644 --- a/MediaBrowser.Controller/Properties/AssemblyInfo.cs +++ b/MediaBrowser.Controller/Properties/AssemblyInfo.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Resources; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -14,6 +15,8 @@ using System.Runtime.InteropServices; [assembly: AssemblyTrademark("")] [assembly: AssemblyCulture("")] [assembly: NeutralResourcesLanguage("en")] +[assembly: InternalsVisibleTo("Jellyfin.Controller.Tests")] +[assembly: InternalsVisibleTo("Jellyfin.Server.Implementations.Tests")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from diff --git a/MediaBrowser.Controller/Resolvers/ItemResolver.cs b/MediaBrowser.Controller/Resolvers/ItemResolver.cs index 7fd54fcc6..e7bf013fa 100644 --- a/MediaBrowser.Controller/Resolvers/ItemResolver.cs +++ b/MediaBrowser.Controller/Resolvers/ItemResolver.cs @@ -23,7 +23,7 @@ namespace MediaBrowser.Controller.Resolvers /// </summary> /// <param name="args">The args.</param> /// <returns>`0.</returns> - public virtual T Resolve(ItemResolveArgs args) + protected internal virtual T Resolve(ItemResolveArgs args) { return null; } @@ -42,7 +42,7 @@ namespace MediaBrowser.Controller.Resolvers /// </summary> /// <param name="args">The args.</param> /// <returns>BaseItem.</returns> - BaseItem IItemResolver.ResolvePath(ItemResolveArgs args) + public BaseItem ResolvePath(ItemResolveArgs args) { var item = Resolve(args); diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 9b4b1db94..8c8fc6b0f 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -102,7 +102,11 @@ namespace MediaBrowser.MediaEncoding.Encoder "tonemap_vaapi", "procamp_vaapi", "overlay_vaapi", - "hwupload_vaapi" + "hwupload_vaapi", + // vulkan + "libplacebo", + "scale_vulkan", + "overlay_vulkan" }; private static readonly IReadOnlyDictionary<int, string[]> _filterOptionsDict = new Dictionary<int, string[]> @@ -111,7 +115,8 @@ namespace MediaBrowser.MediaEncoding.Encoder { 1, new string[] { "tonemap_cuda", "GPU accelerated HDR to SDR tonemapping" } }, { 2, new string[] { "tonemap_opencl", "bt2390" } }, { 3, new string[] { "overlay_opencl", "Action to take when encountering EOF from secondary input" } }, - { 4, new string[] { "overlay_vaapi", "Action to take when encountering EOF from secondary input" } } + { 4, new string[] { "overlay_vaapi", "Action to take when encountering EOF from secondary input" } }, + { 5, new string[] { "overlay_vulkan", "Action to take when encountering EOF from secondary input" } } }; // These are the library versions that corresponds to our minimum ffmpeg version 4.x according to the version table below @@ -351,6 +356,39 @@ namespace MediaBrowser.MediaEncoding.Encoder } } + public bool CheckVulkanDrmDeviceByExtensionName(string renderNodePath, string[] vulkanExtensions) + { + if (!OperatingSystem.IsLinux()) + { + return false; + } + + if (string.IsNullOrEmpty(renderNodePath)) + { + return false; + } + + try + { + var command = "-v verbose -hide_banner -init_hw_device drm=dr:" + renderNodePath + " -init_hw_device vulkan=vk@dr"; + var output = GetProcessOutput(_encoderPath, command, true, null); + foreach (string ext in vulkanExtensions) + { + if (!output.Contains(ext, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error detecting the given drm render node path"); + return false; + } + } + private IEnumerable<string> GetHwaccelTypes() { string? output = null; diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 757a01715..ec3412f90 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -72,6 +72,16 @@ namespace MediaBrowser.MediaEncoding.Encoder private bool _isVaapiDeviceAmd = false; private bool _isVaapiDeviceInteliHD = false; private bool _isVaapiDeviceInteli965 = false; + private bool _isVaapiDeviceSupportVulkanFmtModifier = false; + + private static string[] _vulkanFmtModifierExts = { + "VK_KHR_sampler_ycbcr_conversion", + "VK_EXT_image_drm_format_modifier", + "VK_KHR_external_memory_fd", + "VK_EXT_external_memory_dma_buf", + "VK_KHR_external_semaphore_fd", + "VK_EXT_external_memory_host" + }; private Version _ffmpegVersion = null; private string _ffmpegPath = string.Empty; @@ -110,6 +120,8 @@ namespace MediaBrowser.MediaEncoding.Encoder public bool IsVaapiDeviceInteli965 => _isVaapiDeviceInteli965; + public bool IsVaapiDeviceSupportVulkanFmtModifier => _isVaapiDeviceSupportVulkanFmtModifier; + /// <summary> /// Run at startup or if the user removes a Custom path from transcode page. /// Sets global variables FFmpegPath. @@ -169,6 +181,8 @@ namespace MediaBrowser.MediaEncoding.Encoder _isVaapiDeviceAmd = validator.CheckVaapiDeviceByDriverName("Mesa Gallium driver", options.VaapiDevice); _isVaapiDeviceInteliHD = validator.CheckVaapiDeviceByDriverName("Intel iHD driver", options.VaapiDevice); _isVaapiDeviceInteli965 = validator.CheckVaapiDeviceByDriverName("Intel i965 driver", options.VaapiDevice); + _isVaapiDeviceSupportVulkanFmtModifier = validator.CheckVulkanDrmDeviceByExtensionName(options.VaapiDevice, _vulkanFmtModifierExts); + if (_isVaapiDeviceAmd) { _logger.LogInformation("VAAPI device {RenderNodePath} is AMD GPU", options.VaapiDevice); @@ -181,6 +195,11 @@ namespace MediaBrowser.MediaEncoding.Encoder { _logger.LogInformation("VAAPI device {RenderNodePath} is Intel GPU (i965)", options.VaapiDevice); } + + if (_isVaapiDeviceSupportVulkanFmtModifier) + { + _logger.LogInformation("VAAPI device {RenderNodePath} supports Vulkan DRM format modifier", options.VaapiDevice); + } } } diff --git a/MediaBrowser.Model/IO/IZipClient.cs b/MediaBrowser.Model/IO/IZipClient.cs deleted file mode 100644 index 2448575d1..000000000 --- a/MediaBrowser.Model/IO/IZipClient.cs +++ /dev/null @@ -1,16 +0,0 @@ -#pragma warning disable CS1591 - -using System.IO; - -namespace MediaBrowser.Model.IO -{ - /// <summary> - /// Interface IZipClient. - /// </summary> - public interface IZipClient - { - void ExtractAllFromGz(Stream source, string targetPath, bool overwriteExistingFiles); - - void ExtractFirstFileFromGz(Stream source, string targetPath, string defaultFileName); - } -} diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 3a0e9a225..b00c036e5 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -17,6 +17,7 @@ <ItemGroup> <PackageReference Include="LrcParser" Version="2022.529.1" /> + <PackageReference Include="MetaBrainz.MusicBrainz" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs index 9c27bd7d3..22229e377 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs @@ -1,37 +1,52 @@ -#pragma warning disable CS1591 - using MediaBrowser.Model.Plugins; +using MetaBrainz.MusicBrainz; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; -namespace MediaBrowser.Providers.Plugins.MusicBrainz +/// <summary> +/// MusicBrainz plugin configuration. +/// </summary> +public class PluginConfiguration : BasePluginConfiguration { - public class PluginConfiguration : BasePluginConfiguration - { - private string _server = Plugin.DefaultServer; + private const string DefaultServer = "musicbrainz.org"; - private long _rateLimit = Plugin.DefaultRateLimit; + private const double DefaultRateLimit = 1.0; - public string Server - { - get => _server; - set => _server = value.TrimEnd('/'); - } + private string _server = DefaultServer; + + private double _rateLimit = DefaultRateLimit; + + /// <summary> + /// Gets or sets the server url. + /// </summary> + public string Server + { + get => _server; - public long RateLimit + set => _server = value.TrimEnd('/'); + } + + /// <summary> + /// Gets or sets the rate limit. + /// </summary> + public double RateLimit + { + get => _rateLimit; + set { - get => _rateLimit; - set + if (value < DefaultRateLimit && _server == DefaultServer) { - if (value < Plugin.DefaultRateLimit && _server == Plugin.DefaultServer) - { - _rateLimit = Plugin.DefaultRateLimit; - } - else - { - _rateLimit = value; - } + _rateLimit = DefaultRateLimit; + } + else + { + _rateLimit = value; } } - - public bool ReplaceArtistName { get; set; } } + + /// <summary> + /// Gets or sets a value indicating whether to replace the artist name. + /// </summary> + public bool ReplaceArtistName { get; set; } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs index c54cdda3d..f7850781e 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz album artist external id. +/// </summary> +public class MusicBrainzAlbumArtistExternalId : IExternalId { - public class MusicBrainzAlbumArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzAlbumArtist.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzAlbumArtist.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio; - } + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs index 8f7fadd06..a9d4472e7 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz album external id. +/// </summary> +public class MusicBrainzAlbumExternalId : IExternalId { - public class MusicBrainzAlbumExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzAlbum.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzAlbum.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Album; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Album; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/release/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/release/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 915fb97fd..4d9feca6d 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -1,805 +1,265 @@ -#nullable disable - -#pragma warning disable CS1591, SA1401 - using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; using System.Linq; -using System.Net; using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; -using System.Xml; -using MediaBrowser.Common.Net; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Providers.Music -{ - public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable - { - /// <summary> - /// For each single MB lookup/search, this is the maximum number of - /// attempts that shall be made whilst receiving a 503 Server - /// Unavailable (indicating throttled) response. - /// </summary> - private const uint MusicBrainzQueryAttempts = 5u; - - /// <summary> - /// The Jellyfin user-agent is unrestricted but source IP must not exceed - /// one request per second, therefore we rate limit to avoid throttling. - /// Be prudent, use a value slightly above the minimum required. - /// https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting. - /// </summary> - private readonly long _musicBrainzQueryIntervalMs; - - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<MusicBrainzAlbumProvider> _logger; - - private readonly string _musicBrainzBaseUrl; - - private SemaphoreSlim _apiRequestLock = new SemaphoreSlim(1, 1); - private Stopwatch _stopWatchMusicBrainz = new Stopwatch(); - - public MusicBrainzAlbumProvider( - IHttpClientFactory httpClientFactory, - ILogger<MusicBrainzAlbumProvider> logger) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - - _musicBrainzBaseUrl = Plugin.Instance.Configuration.Server; - _musicBrainzQueryIntervalMs = Plugin.Instance.Configuration.RateLimit; - - // Use a stopwatch to ensure we don't exceed the MusicBrainz rate limit - _stopWatchMusicBrainz.Start(); - - Current = this; - } - - internal static MusicBrainzAlbumProvider Current { get; private set; } - - /// <inheritdoc /> - public string Name => "MusicBrainz"; +using MediaBrowser.Providers.Music; +using MetaBrainz.MusicBrainz; +using MetaBrainz.MusicBrainz.Interfaces.Entities; +using MetaBrainz.MusicBrainz.Interfaces.Searches; - /// <inheritdoc /> - public int Order => 0; +namespace MediaBrowser.Providers.Plugins.MusicBrainz; - /// <inheritdoc /> - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken) - { - var releaseId = searchInfo.GetReleaseId(); - var releaseGroupId = searchInfo.GetReleaseGroupId(); - - string url; - - if (!string.IsNullOrEmpty(releaseId)) - { - url = "/ws/2/release/?query=reid:" + releaseId.ToString(CultureInfo.InvariantCulture); - } - else if (!string.IsNullOrEmpty(releaseGroupId)) - { - url = "/ws/2/release?release-group=" + releaseGroupId.ToString(CultureInfo.InvariantCulture); - } - else - { - var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId(); - - if (!string.IsNullOrWhiteSpace(artistMusicBrainzId)) - { - url = string.Format( - CultureInfo.InvariantCulture, - "/ws/2/release/?query=\"{0}\" AND arid:{1}", - WebUtility.UrlEncode(searchInfo.Name), - artistMusicBrainzId); - } - else - { - // I'm sure there is a better way but for now it resolves search for 12" Mixes - var queryName = searchInfo.Name.Replace("\"", string.Empty, StringComparison.Ordinal); - - url = string.Format( - CultureInfo.InvariantCulture, - "/ws/2/release/?query=\"{0}\" AND artist:\"{1}\"", - WebUtility.UrlEncode(queryName), - WebUtility.UrlEncode(searchInfo.GetAlbumArtist())); - } - } - - if (!string.IsNullOrWhiteSpace(url)) - { - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return GetResultsFromResponse(stream); - } - - return Enumerable.Empty<RemoteSearchResult>(); - } +/// <summary> +/// Music album metadata provider for MusicBrainz. +/// </summary> +public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable +{ + private readonly Query _musicBrainzQuery; - private IEnumerable<RemoteSearchResult> GetResultsFromResponse(Stream stream) - { - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings() + /// <summary> + /// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class. + /// </summary> + public MusicBrainzAlbumProvider() + { + MusicBrainz.Plugin.Instance!.ConfigurationChanged += (_, _) => { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true + Query.DefaultServer = MusicBrainz.Plugin.Instance.Configuration.Server; + Query.DelayBetweenRequests = MusicBrainz.Plugin.Instance.Configuration.RateLimit; }; - using var reader = XmlReader.Create(oReader, settings); - var results = ReleaseResult.Parse(reader); - - return results.Select(i => - { - var result = new RemoteSearchResult - { - Name = i.Title, - ProductionYear = i.Year - }; + _musicBrainzQuery = new Query(); + } - if (i.Artists.Count > 0) - { - result.AlbumArtist = new RemoteSearchResult - { - SearchProviderName = Name, - Name = i.Artists[0].Item1 - }; + /// <inheritdoc /> + public string Name => "MusicBrainz"; - result.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, i.Artists[0].Item2); - } + /// <inheritdoc /> + public int Order => 0; - if (!string.IsNullOrWhiteSpace(i.ReleaseId)) - { - result.SetProviderId(MetadataProvider.MusicBrainzAlbum, i.ReleaseId); - } - - if (!string.IsNullOrWhiteSpace(i.ReleaseGroupId)) - { - result.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, i.ReleaseGroupId); - } + /// <inheritdoc /> + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken) + { + var releaseId = searchInfo.GetReleaseId(); + var releaseGroupId = searchInfo.GetReleaseGroupId(); - return result; - }); + if (!string.IsNullOrEmpty(releaseId)) + { + var releaseResult = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); + return GetReleaseResult(releaseResult).SingleItemAsEnumerable(); } - /// <inheritdoc /> - public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) + if (!string.IsNullOrEmpty(releaseGroupId)) { - var releaseId = info.GetReleaseId(); - var releaseGroupId = info.GetReleaseGroupId(); - - var result = new MetadataResult<MusicAlbum> - { - Item = new MusicAlbum() - }; - - // If we have a release group Id but not a release Id... - if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) - { - releaseId = await GetReleaseIdFromReleaseGroupId(releaseGroupId, cancellationToken).ConfigureAwait(false); - result.HasMetadata = true; - } - - if (string.IsNullOrWhiteSpace(releaseId)) - { - var artistMusicBrainzId = info.GetMusicBrainzArtistId(); - - var releaseResult = await GetReleaseResult(artistMusicBrainzId, info.GetAlbumArtist(), info.Name, cancellationToken).ConfigureAwait(false); - - if (releaseResult != null) - { - if (!string.IsNullOrWhiteSpace(releaseResult.ReleaseId)) - { - releaseId = releaseResult.ReleaseId; - result.HasMetadata = true; - } - - if (!string.IsNullOrWhiteSpace(releaseResult.ReleaseGroupId)) - { - releaseGroupId = releaseResult.ReleaseGroupId; - result.HasMetadata = true; - } - - result.Item.ProductionYear = releaseResult.Year; - result.Item.Overview = releaseResult.Overview; - } - } + var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); + return GetReleaseGroupResult(releaseGroupResult.Releases); + } - // If we have a release Id but not a release group Id... - if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) - { - releaseGroupId = await GetReleaseGroupFromReleaseId(releaseId, cancellationToken).ConfigureAwait(false); - result.HasMetadata = true; - } + var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId(); - if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId)) - { - result.HasMetadata = true; - } + if (!string.IsNullOrWhiteSpace(artistMusicBrainzId)) + { + var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) + .ConfigureAwait(false); - if (result.HasMetadata) + if (releaseSearchResults.Results.Count > 0) { - if (!string.IsNullOrEmpty(releaseId)) - { - result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId); - } - - if (!string.IsNullOrEmpty(releaseGroupId)) - { - result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId); - } + return GetReleaseSearchResult(releaseSearchResults.Results); } - - return result; } - - private Task<ReleaseResult> GetReleaseResult(string artistMusicBrainId, string artistName, string albumName, CancellationToken cancellationToken) + else { - if (!string.IsNullOrEmpty(artistMusicBrainId)) - { - return GetReleaseResult(albumName, artistMusicBrainId, cancellationToken); - } + // I'm sure there is a better way but for now it resolves search for 12" Mixes + var queryName = searchInfo.Name.Replace("\"", string.Empty, StringComparison.Ordinal); - if (string.IsNullOrWhiteSpace(artistName)) + var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", null, null, false, cancellationToken) + .ConfigureAwait(false); + + if (releaseSearchResults.Results.Count > 0) { - return Task.FromResult(new ReleaseResult()); + return GetReleaseSearchResult(releaseSearchResults.Results); } - - return GetReleaseResultByArtistName(albumName, artistName, cancellationToken); } - private async Task<ReleaseResult> GetReleaseResult(string albumName, string artistId, CancellationToken cancellationToken) - { - var url = string.Format( - CultureInfo.InvariantCulture, - "/ws/2/release/?query=\"{0}\" AND arid:{1}", - WebUtility.UrlEncode(albumName), - artistId); - - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true - }; + return Enumerable.Empty<RemoteSearchResult>(); + } - using var reader = XmlReader.Create(oReader, settings); - return ReleaseResult.Parse(reader).FirstOrDefault(); + private IEnumerable<RemoteSearchResult> GetReleaseSearchResult(IEnumerable<ISearchResult<IRelease>>? releaseSearchResults) + { + if (releaseSearchResults is null) + { + yield break; } - private async Task<ReleaseResult> GetReleaseResultByArtistName(string albumName, string artistName, CancellationToken cancellationToken) + foreach (var result in releaseSearchResults) { - var url = string.Format( - CultureInfo.InvariantCulture, - "/ws/2/release/?query=\"{0}\" AND artist:\"{1}\"", - WebUtility.UrlEncode(albumName), - WebUtility.UrlEncode(artistName)); - - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings() - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true - }; - - using var reader = XmlReader.Create(oReader, settings); - return ReleaseResult.Parse(reader).FirstOrDefault(); + yield return GetReleaseResult(result.Item); } + } - private static (string Name, string ArtistId) ParseArtistCredit(XmlReader reader) + private IEnumerable<RemoteSearchResult> GetReleaseGroupResult(IEnumerable<IRelease>? releaseSearchResults) + { + if (releaseSearchResults is null) { - reader.MoveToContent(); - reader.Read(); - - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "name-credit": - { - if (reader.IsEmptyElement) - { - reader.Read(); - break; - } - - using var subReader = reader.ReadSubtree(); - return ParseArtistNameCredit(subReader); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return default; + yield break; } - private static (string Name, string ArtistId) ParseArtistNameCredit(XmlReader reader) + foreach (var result in releaseSearchResults) { - reader.MoveToContent(); - reader.Read(); - - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "artist": - { - if (reader.IsEmptyElement) - { - reader.Read(); - break; - } - - var id = reader.GetAttribute("id"); - using var subReader = reader.ReadSubtree(); - return ParseArtistArtistCredit(subReader, id); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return (null, null); + yield return GetReleaseResult(result); } + } - private static (string Name, string ArtistId) ParseArtistArtistCredit(XmlReader reader, string artistId) + private RemoteSearchResult GetReleaseResult(IRelease releaseSearchResult) + { + var searchResult = new RemoteSearchResult { - reader.MoveToContent(); - reader.Read(); - - string name = null; + Name = releaseSearchResult.Title, + ProductionYear = releaseSearchResult.Date?.Year, + PremiereDate = releaseSearchResult.Date?.NearestDate + }; - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + if (releaseSearchResult.ArtistCredit?.Count > 0) + { + searchResult.AlbumArtist = new RemoteSearchResult + { + SearchProviderName = Name, + Name = releaseSearchResult.ArtistCredit[0].Name + }; - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + if (releaseSearchResult.ArtistCredit[0].Artist?.Id is not null) { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "name": - { - name = reader.ReadElementContentAsString(); - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } + searchResult.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, releaseSearchResult.ArtistCredit[0].Artist!.Id.ToString()); } - - return (name, artistId); } - private async Task<string> GetReleaseIdFromReleaseGroupId(string releaseGroupId, CancellationToken cancellationToken) - { - var url = "/ws/2/release?release-group=" + releaseGroupId.ToString(CultureInfo.InvariantCulture); - - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true - }; - - using var reader = XmlReader.Create(oReader, settings); - var result = ReleaseResult.Parse(reader).FirstOrDefault(); + searchResult.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseSearchResult.Id.ToString()); - return result?.ReleaseId; + if (releaseSearchResult.ReleaseGroup?.Id is not null) + { + searchResult.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseSearchResult.ReleaseGroup.Id.ToString()); } - /// <summary> - /// Gets the release group id internal. - /// </summary> - /// <param name="releaseEntryId">The release entry id.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{System.String}.</returns> - private async Task<string> GetReleaseGroupFromReleaseId(string releaseEntryId, CancellationToken cancellationToken) - { - var url = "/ws/2/release-group/?query=reid:" + releaseEntryId.ToString(CultureInfo.InvariantCulture); + return searchResult; + } - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true, - Async = true - }; + /// <inheritdoc /> + public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) + { + // TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it. + var releaseId = info.GetReleaseId(); + var releaseGroupId = info.GetReleaseGroupId(); - using var reader = XmlReader.Create(oReader, settings); - await reader.MoveToContentAsync().ConfigureAwait(false); - await reader.ReadAsync().ConfigureAwait(false); + var result = new MetadataResult<MusicAlbum> + { + Item = new MusicAlbum() + }; - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + // If there is a release group, but no release ID, try to match the release + if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) + { + // TODO: Actually try to match the release. Simply taking the first result is stupid. + var releaseGroup = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); + var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null; + if (release != null) { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release-group-list": - { - if (reader.IsEmptyElement) - { - await reader.ReadAsync().ConfigureAwait(false); - continue; - } - - using var subReader = reader.ReadSubtree(); - return GetFirstReleaseGroupId(subReader); - } - - default: - { - await reader.SkipAsync().ConfigureAwait(false); - break; - } - } - } - else - { - await reader.ReadAsync().ConfigureAwait(false); - } + releaseId = release.Id.ToString(); + result.HasMetadata = true; } - - return null; } - private string GetFirstReleaseGroupId(XmlReader reader) + // If there is no release ID, lookup a release with the info we have + if (string.IsNullOrWhiteSpace(releaseId)) { - reader.MoveToContent(); - reader.Read(); + var artistMusicBrainzId = info.GetMusicBrainzArtistId(); + IRelease? releaseResult = null; - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + if (!string.IsNullOrEmpty(artistMusicBrainzId)) { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release-group": - { - return reader.GetAttribute("id"); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } + var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) + .ConfigureAwait(false); + releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null; } - - return null; - } - - /// <summary> - /// Makes request to MusicBrainz server and awaits a response. - /// A 503 Service Unavailable response indicates throttling to maintain a rate limit. - /// A number of retries shall be made in order to try and satisfy the request before - /// giving up and returning null. - /// </summary> - /// <param name="url">Address of MusicBrainz server.</param> - /// <param name="cancellationToken">CancellationToken to use for method.</param> - /// <returns>Returns response from MusicBrainz service.</returns> - internal async Task<HttpResponseMessage> GetMusicBrainzResponse(string url, CancellationToken cancellationToken) - { - await _apiRequestLock.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + else if (!string.IsNullOrEmpty(info.GetAlbumArtist())) { - HttpResponseMessage response; - var attempts = 0u; - var requestUrl = _musicBrainzBaseUrl.TrimEnd('/') + url; + var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", null, null, false, cancellationToken) + .ConfigureAwait(false); + releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null; + } - do - { - attempts++; - - if (_stopWatchMusicBrainz.ElapsedMilliseconds < _musicBrainzQueryIntervalMs) - { - // MusicBrainz is extremely adamant about limiting to one request per second. - var delayMs = _musicBrainzQueryIntervalMs - _stopWatchMusicBrainz.ElapsedMilliseconds; - await Task.Delay((int)delayMs, cancellationToken).ConfigureAwait(false); - } - - // Write time since last request to debug log as evidence we're meeting rate limit - // requirement, before resetting stopwatch back to zero. - _logger.LogDebug("GetMusicBrainzResponse: Time since previous request: {0} ms", _stopWatchMusicBrainz.ElapsedMilliseconds); - _stopWatchMusicBrainz.Restart(); - - using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); - response = await _httpClientFactory - .CreateClient(NamedClient.MusicBrainz) - .SendAsync(request, cancellationToken) - .ConfigureAwait(false); - - // We retry a finite number of times, and only whilst MB is indicating 503 (throttling). - } - while (attempts < MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable); + if (releaseResult != null) + { + releaseId = releaseResult.Id.ToString(); - // Log error if unable to query MB database due to throttling. - if (attempts == MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable) + if (releaseResult.ReleaseGroup?.Id is not null) { - _logger.LogError("GetMusicBrainzResponse: 503 Service Unavailable (throttled) response received {0} times whilst requesting {1}", attempts, requestUrl); + releaseGroupId = releaseResult.ReleaseGroup.Id.ToString(); } - return response; - } - finally - { - _apiRequestLock.Release(); + result.HasMetadata = true; + result.Item.ProductionYear = releaseResult.Date?.Year; + result.Item.Overview = releaseResult.Annotation; } } - /// <inheritdoc /> - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + // If we have a release ID but not a release group ID, lookup the release group + if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) { - throw new NotImplementedException(); + var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Releases, cancellationToken).ConfigureAwait(false); + releaseGroupId = release.ReleaseGroup?.Id.ToString(); + result.HasMetadata = true; } - protected virtual void Dispose(bool disposing) + // If we have a release ID and a release group ID + if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId)) { - if (disposing) - { - _apiRequestLock?.Dispose(); - } + result.HasMetadata = true; } - /// <inheritdoc /> - public void Dispose() + if (result.HasMetadata) { - Dispose(true); - GC.SuppressFinalize(this); - } - - private class ReleaseResult - { - public string ReleaseId; - public string ReleaseGroupId; - public string Title; - public string Overview; - public int? Year; - - public List<(string, string)> Artists = new(); - - public static IEnumerable<ReleaseResult> Parse(XmlReader reader) + if (!string.IsNullOrEmpty(releaseId)) { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release-list": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using var subReader = reader.ReadSubtree(); - return ParseReleaseList(subReader).ToList(); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return Enumerable.Empty<ReleaseResult>(); + result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId); } - private static IEnumerable<ReleaseResult> ParseReleaseList(XmlReader reader) + if (!string.IsNullOrEmpty(releaseGroupId)) { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - var releaseId = reader.GetAttribute("id"); - - using var subReader = reader.ReadSubtree(); - var release = ParseRelease(subReader, releaseId); - if (release != null) - { - yield return release; - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } + result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId); } + } - private static ReleaseResult ParseRelease(XmlReader reader, string releaseId) - { - var result = new ReleaseResult - { - ReleaseId = releaseId - }; - - reader.MoveToContent(); - reader.Read(); + return result; + } - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + /// <inheritdoc /> + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "title": - { - result.Title = reader.ReadElementContentAsString(); - break; - } - - case "date": - { - var val = reader.ReadElementContentAsString(); - if (DateTime.TryParse(val, out var date)) - { - result.Year = date.Year; - } - - break; - } - - case "annotation": - { - result.Overview = reader.ReadElementContentAsString(); - break; - } - - case "release-group": - { - result.ReleaseGroupId = reader.GetAttribute("id"); - reader.Skip(); - break; - } - - case "artist-credit": - { - if (reader.IsEmptyElement) - { - reader.Read(); - break; - } - - using var subReader = reader.ReadSubtree(); - var artist = ParseArtistCredit(subReader); - - if (!string.IsNullOrEmpty(artist.Name)) - { - result.Artists.Add(artist); - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - return result; - } + /// <summary> + /// Dispose all resources. + /// </summary> + /// <param name="disposing">Whether to dispose.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _musicBrainzQuery.Dispose(); } } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs index 941ffea72..b89e67270 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz artist external id. +/// </summary> +public class MusicBrainzArtistExternalId : IExternalId { - public class MusicBrainzArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzArtist.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzArtist.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is MusicArtist; - } + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is MusicArtist; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 906a42f36..2cc3a13be 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -1,15 +1,7 @@ -#nullable disable - -#pragma warning disable CS1591 - using System; using System.Collections.Generic; -using System.Globalization; -using System.IO; using System.Linq; -using System.Net; using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; @@ -18,257 +10,152 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; - -namespace MediaBrowser.Providers.Music -{ - public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo> - { - public string Name => "MusicBrainz"; +using MediaBrowser.Providers.Music; +using MetaBrainz.MusicBrainz; +using MetaBrainz.MusicBrainz.Interfaces.Entities; +using MetaBrainz.MusicBrainz.Interfaces.Searches; - /// <inheritdoc /> - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) - { - var musicBrainzId = searchInfo.GetMusicBrainzArtistId(); +namespace MediaBrowser.Providers.Plugins.MusicBrainz; - if (!string.IsNullOrWhiteSpace(musicBrainzId)) - { - var url = "/ws/2/artist/?query=arid:{0}" + musicBrainzId.ToString(CultureInfo.InvariantCulture); +/// <summary> +/// MusicBrainz artist provider. +/// </summary> +public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable +{ + private readonly Query _musicBrainzQuery; - using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return GetResultsFromResponse(stream); - } - else + /// <summary> + /// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class. + /// </summary> + public MusicBrainzArtistProvider() + { + MusicBrainz.Plugin.Instance!.ConfigurationChanged += (_, _) => { - // They seem to throw bad request failures on any term with a slash - var nameToSearch = searchInfo.Name.Replace('/', ' '); - - var url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=\"{0}\"&dismax=true", UrlEncode(nameToSearch)); - - using (var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) - await using (var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) - { - var results = GetResultsFromResponse(stream).ToList(); + Query.DefaultServer = MusicBrainz.Plugin.Instance.Configuration.Server; + Query.DelayBetweenRequests = MusicBrainz.Plugin.Instance.Configuration.RateLimit; + }; - if (results.Count > 0) - { - return results; - } - } + _musicBrainzQuery = new Query(); + } - if (searchInfo.Name.HasDiacritics()) - { - // Try again using the search with accent characters url - url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=artistaccent:\"{0}\"", UrlEncode(nameToSearch)); + /// <inheritdoc /> + public string Name => "MusicBrainz"; - using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return GetResultsFromResponse(stream); - } - } + /// <inheritdoc /> + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) + { + var artistId = searchInfo.GetMusicBrainzArtistId(); - return Enumerable.Empty<RemoteSearchResult>(); + if (!string.IsNullOrWhiteSpace(artistId)) + { + var artistResult = await _musicBrainzQuery.LookupArtistAsync(new Guid(artistId), Include.Aliases, null, null, cancellationToken).ConfigureAwait(false); + return GetResultFromResponse(artistResult).SingleItemAsEnumerable(); } - private IEnumerable<RemoteSearchResult> GetResultsFromResponse(Stream stream) + var artistSearchResults = await _musicBrainzQuery.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken) + .ConfigureAwait(false); + if (artistSearchResults.Results.Count > 0) { - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings() - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true - }; - - using var reader = XmlReader.Create(oReader, settings); - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "artist-list": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using var subReader = reader.ReadSubtree(); - return ParseArtistList(subReader).ToList(); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return Enumerable.Empty<RemoteSearchResult>(); + return GetResultsFromResponse(artistSearchResults.Results); } - private IEnumerable<RemoteSearchResult> ParseArtistList(XmlReader reader) + if (searchInfo.Name.HasDiacritics()) { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + // Try again using the search with an accented characters query + var artistAccentsSearchResults = await _musicBrainzQuery.FindArtistsAsync($"artistaccent:\"{searchInfo.Name}\"", null, null, false, cancellationToken) + .ConfigureAwait(false); + if (artistAccentsSearchResults.Results.Count > 0) { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "artist": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - var mbzId = reader.GetAttribute("id"); - - using var subReader = reader.ReadSubtree(); - var artist = ParseArtist(subReader, mbzId); - if (artist != null) - { - yield return artist; - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } + return GetResultsFromResponse(artistAccentsSearchResults.Results); } } - private RemoteSearchResult ParseArtist(XmlReader reader, string artistId) - { - var result = new RemoteSearchResult(); - - reader.MoveToContent(); - reader.Read(); + return Enumerable.Empty<RemoteSearchResult>(); + } - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IArtist>>? releaseSearchResults) + { + if (releaseSearchResults is null) + { + yield break; + } - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "name": - { - result.Name = reader.ReadElementContentAsString(); - break; - } + foreach (var result in releaseSearchResults) + { + yield return GetResultFromResponse(result.Item); + } + } - case "annotation": - { - result.Overview = reader.ReadElementContentAsString(); - break; - } + private RemoteSearchResult GetResultFromResponse(IArtist artist) + { + var searchResult = new RemoteSearchResult + { + Name = artist.Name, + ProductionYear = artist.LifeSpan?.Begin?.Year, + PremiereDate = artist.LifeSpan?.Begin?.NearestDate + }; - default: - { - // there is sort-name if ever needed - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } + searchResult.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString()); - result.SetProviderId(MetadataProvider.MusicBrainzArtist, artistId); + return searchResult; + } - if (string.IsNullOrWhiteSpace(artistId) || string.IsNullOrWhiteSpace(result.Name)) - { - return null; - } + /// <inheritdoc /> + public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<MusicArtist> { Item = new MusicArtist() }; - return result; - } + var musicBrainzId = info.GetMusicBrainzArtistId(); - /// <inheritdoc /> - public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + if (string.IsNullOrWhiteSpace(musicBrainzId)) { - var result = new MetadataResult<MusicArtist> - { - Item = new MusicArtist() - }; + var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false); - var musicBrainzId = info.GetMusicBrainzArtistId(); + var singleResult = searchResults.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(musicBrainzId)) + if (singleResult != null) { - var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false); + musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist); + result.Item.Overview = singleResult.Overview; - var singleResult = searchResults.FirstOrDefault(); - - if (singleResult != null) + if (Plugin.Instance!.Configuration.ReplaceArtistName) { - musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist); - result.Item.Overview = singleResult.Overview; - - if (Plugin.Instance.Configuration.ReplaceArtistName) - { - result.Item.Name = singleResult.Name; - } + result.Item.Name = singleResult.Name; } } - - if (!string.IsNullOrWhiteSpace(musicBrainzId)) - { - result.HasMetadata = true; - result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId); - } - - return result; } - /// <summary> - /// Encodes an URL. - /// </summary> - /// <param name="name">The name.</param> - /// <returns>System.String.</returns> - private static string UrlEncode(string name) + if (!string.IsNullOrWhiteSpace(musicBrainzId)) { - return WebUtility.UrlEncode(name); + result.HasMetadata = true; + result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId); } - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + return result; + } + + /// <inheritdoc /> + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose all resources. + /// </summary> + /// <param name="disposing">Whether to dispose.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) { - throw new NotImplementedException(); + _musicBrainzQuery.Dispose(); } } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs index 05db2d98f..fdaa5574f 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz other artist external id. +/// </summary> +public class MusicBrainzOtherArtistExternalId : IExternalId { - public class MusicBrainzOtherArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzArtist.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzArtist.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs index acb652fe0..0baab9955 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz release group external id. +/// </summary> +public class MusicBrainzReleaseGroupExternalId : IExternalId { - public class MusicBrainzReleaseGroupExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzReleaseGroup.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzReleaseGroup.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/release-group/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/release-group/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs index 14805b9b7..5c974c411 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz track id. +/// </summary> +public class MusicBrainzTrackId : IExternalId { - public class MusicBrainzTrackId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzTrack.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzTrack.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Track; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Track; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/track/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/track/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio; - } + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs index cfa10dd64..39cfd727f 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs @@ -1,45 +1,64 @@ -#nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; +using System.Net.Http.Headers; +using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; +using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; +using MetaBrainz.MusicBrainz; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Plugins.MusicBrainz +/// <summary> +/// Plugin instance. +/// </summary> +public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { - public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages + /// <summary> + /// Initializes a new instance of the <see cref="Plugin"/> class. + /// </summary> + /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> + /// <param name="applicationHost">Instance of the <see cref="IApplicationHost"/> interface.</param> + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IApplicationHost applicationHost) + : base(applicationPaths, xmlSerializer) { - public const string DefaultServer = "https://musicbrainz.org"; - - public const long DefaultRateLimit = 2000u; + Instance = this; - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - : base(applicationPaths, xmlSerializer) - { - Instance = this; - } + // TODO: Change this to "JellyfinMusicBrainzPlugin" once we take it out of the server repo. + Query.DefaultUserAgent.Add(new ProductInfoHeaderValue(applicationHost.Name.Replace(' ', '-'), applicationHost.ApplicationVersionString)); + Query.DefaultUserAgent.Add(new ProductInfoHeaderValue($"({applicationHost.ApplicationUserAgentAddress})")); + Query.DelayBetweenRequests = Instance.Configuration.RateLimit; + Query.DefaultServer = Instance.Configuration.Server; + } - public static Plugin Instance { get; private set; } + /// <summary> + /// Gets the current plugin instance. + /// </summary> + public static Plugin? Instance { get; private set; } - public override Guid Id => new Guid("8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"); + /// <inheritdoc /> + public override Guid Id => new Guid("8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"); - public override string Name => "MusicBrainz"; + /// <inheritdoc /> + public override string Name => "MusicBrainz"; - public override string Description => "Get artist and album metadata from any MusicBrainz server."; + /// <inheritdoc /> + public override string Description => "Get artist and album metadata from any MusicBrainz server."; - // TODO remove when plugin removed from server. - public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml"; + /// <inheritdoc /> + // TODO remove when plugin removed from server. + public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml"; - public IEnumerable<PluginPageInfo> GetPages() + /// <inheritdoc /> + public IEnumerable<PluginPageInfo> GetPages() + { + yield return new PluginPageInfo { - yield return new PluginPageInfo - { - Name = Name, - EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" - }; - } + Name = Name, + EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" + }; } } diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 0bae42bc8..1bdef2d59 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -13,7 +13,7 @@ RUN yum update -yq \ && yum install -yq @buildsys-build rpmdevtools yum-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel git wget # Install DotNET SDK -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/d3e46476-4494-41b7-a628-c517794c5a6a/6066215f6c0a18b070e8e6e8b715de0b/dotnet-sdk-6.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 20aa777b6..945bf8116 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -12,7 +12,7 @@ RUN dnf update -yq \ && dnf install -yq @buildsys-build rpmdevtools git dnf-plugins-core libcurl-devel fontconfig-devel freetype-devel openssl-devel glibc-devel libicu-devel systemd wget make # Install DotNET SDK -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/d3e46476-4494-41b7-a628-c517794c5a6a/6066215f6c0a18b070e8e6e8b715de0b/dotnet-sdk-6.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index ccc0f76cd..a63cd6527 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -17,7 +17,7 @@ RUN apt-get update -yqq \ libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/d3e46476-4494-41b7-a628-c517794c5a6a/6066215f6c0a18b070e8e6e8b715de0b/dotnet-sdk-6.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index 893180974..2b9ea9bf6 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -16,7 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/d3e46476-4494-41b7-a628-c517794c5a6a/6066215f6c0a18b070e8e6e8b715de0b/dotnet-sdk-6.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index bf1edf777..3d3e49af8 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -16,7 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/d3e46476-4494-41b7-a628-c517794c5a6a/6066215f6c0a18b070e8e6e8b715de0b/dotnet-sdk-6.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj index 7cd98c29a..81c8f2ba9 100644 --- a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj +++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj @@ -18,7 +18,7 @@ <ItemGroup> <PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="Moq" Version="4.16.1" /> + <PackageReference Include="Moq" Version="4.18.2" /> <PackageReference Include="SharpFuzz" Version="1.6.2" /> </ItemGroup> diff --git a/jellyfin.ruleset b/jellyfin.ruleset index 5ac5f4923..8144db93d 100644 --- a/jellyfin.ruleset +++ b/jellyfin.ruleset @@ -5,8 +5,16 @@ <Rule Id="SA1000" Action="Error" /> <!-- error on SA1001: Commas should not be preceded by whitespace --> <Rule Id="SA1001" Action="Error" /> + <!-- error on SA1106: Code should not contain empty statements --> + <Rule Id="SA1106" Action="Error" /> + <!-- error on SA1107: Code should not contain multiple statements on one line --> + <Rule Id="SA1107" Action="Error" /> + <!-- error on SA1028: Code should not contain trailing whitespace --> + <Rule Id="SA1028" Action="Error" /> <!-- error on SA1117: The parameters should all be placed on the same line or each parameter should be placed on its own line --> <Rule Id="SA1117" Action="Error" /> + <!-- error on SA1137: Elements should have the same indentation --> + <Rule Id="SA1137" Action="Error" /> <!-- error on SA1142: Refer to tuple fields by name --> <Rule Id="SA1142" Action="Error" /> <!-- error on SA1210: Using directives should be ordered alphabetically by the namespaces --> @@ -69,6 +77,8 @@ <Rule Id="CA1307" Action="Error" /> <!-- error on CA1309: Use ordinal StringComparison --> <Rule Id="CA1309" Action="Error" /> + <!-- error on CA1310: Specify StringComparison for correctness --> + <Rule Id="CA1310" Action="Error" /> <!-- error on CA1725: Parameter names should match base declaration --> <Rule Id="CA1725" Action="Error" /> <!-- error on CA1725: Call async methods when in an async method --> diff --git a/src/Jellyfin.Extensions/EnumerableExtensions.cs b/src/Jellyfin.Extensions/EnumerableExtensions.cs index a31a57dc6..fd46358a4 100644 --- a/src/Jellyfin.Extensions/EnumerableExtensions.cs +++ b/src/Jellyfin.Extensions/EnumerableExtensions.cs @@ -1,42 +1,31 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Extensions +namespace Jellyfin.Extensions; + +/// <summary> +/// Static extensions for the <see cref="IEnumerable{T}"/> interface. +/// </summary> +public static class EnumerableExtensions { /// <summary> - /// Static extensions for the <see cref="IEnumerable{T}"/> interface. + /// Determines whether the value is contained in the source collection. /// </summary> - public static class EnumerableExtensions + /// <param name="source">An instance of the <see cref="IEnumerable{String}"/> interface.</param> + /// <param name="value">The value to look for in the collection.</param> + /// <param name="stringComparison">The string comparison.</param> + /// <returns>A value indicating whether the value is contained in the collection.</returns> + /// <exception cref="ArgumentNullException">The source is null.</exception> + public static bool Contains(this IEnumerable<string> source, ReadOnlySpan<char> value, StringComparison stringComparison) { - /// <summary> - /// Determines whether the value is contained in the source collection. - /// </summary> - /// <param name="source">An instance of the <see cref="IEnumerable{String}"/> interface.</param> - /// <param name="value">The value to look for in the collection.</param> - /// <param name="stringComparison">The string comparison.</param> - /// <returns>A value indicating whether the value is contained in the collection.</returns> - /// <exception cref="ArgumentNullException">The source is null.</exception> - public static bool Contains(this IEnumerable<string> source, ReadOnlySpan<char> value, StringComparison stringComparison) - { - ArgumentNullException.ThrowIfNull(source); - - if (source is IList<string> list) - { - int len = list.Count; - for (int i = 0; i < len; i++) - { - if (value.Equals(list[i], stringComparison)) - { - return true; - } - } - - return false; - } + ArgumentNullException.ThrowIfNull(source); - foreach (string element in source) + if (source is IList<string> list) + { + int len = list.Count; + for (int i = 0; i < len; i++) { - if (value.Equals(element, stringComparison)) + if (value.Equals(list[i], stringComparison)) { return true; } @@ -44,5 +33,26 @@ namespace Jellyfin.Extensions return false; } + + foreach (string element in source) + { + if (value.Equals(element, stringComparison)) + { + return true; + } + } + + return false; + } + + /// <summary> + /// Gets an IEnumerable from a single item. + /// </summary> + /// <param name="item">The item to return.</param> + /// <typeparam name="T">The type of item.</typeparam> + /// <returns>The IEnumerable{T}.</returns> + public static IEnumerable<T> SingleItemAsEnumerable<T>(this T item) + { + yield return item; } } diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs new file mode 100644 index 000000000..985bbcde1 --- /dev/null +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -0,0 +1,18 @@ +using MediaBrowser.Controller.Entities; +using Xunit; + +namespace Jellyfin.Controller.Tests.Entities +{ + public class BaseItemTests + { + [Theory] + [InlineData("", "")] + [InlineData("1", "0000000001")] + [InlineData("t", "t")] + [InlineData("test", "test")] + [InlineData("test1", "test0000000001")] + [InlineData("1test 2", "0000000001test 0000000002")] + public void BaseItem_ModifySortChunks_Valid(string input, string expected) + => Assert.Equal(expected, BaseItem.ModifySortChunks(input)); + } +} diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicAlbumNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicAlbumNfoProviderTests.cs index eea8cb50a..8f276d03f 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicAlbumNfoProviderTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicAlbumNfoProviderTests.cs @@ -7,7 +7,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Music; +using MediaBrowser.Providers.Plugins.MusicBrainz; using MediaBrowser.XbmcMetadata.Parsers; using Microsoft.Extensions.Logging.Abstractions; using Moq; diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicArtistNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicArtistNfoParserTests.cs index 8ca3dd96e..78183d9ff 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicArtistNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicArtistNfoParserTests.cs @@ -7,7 +7,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Music; +using MediaBrowser.Providers.Plugins.MusicBrainz; using MediaBrowser.XbmcMetadata.Parsers; using Microsoft.Extensions.Logging.Abstractions; using Moq; |
