diff options
39 files changed, 357 insertions, 175 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 1d65527d9..302ac67b6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.2", + "version": "10.0.3", "commands": [ "dotnet-ef" ] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8b6b12c31..c67c29237 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,17 +1,31 @@ { "name": "Development Jellyfin Server", - "image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm", + "image": "mcr.microsoft.com/devcontainers/dotnet:10.0-noble", "service": "app", "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", // restores nuget packages, installs the dotnet workloads and installs the dev https certificate "postStartCommand": "sudo dotnet restore; sudo dotnet workload update; sudo dotnet dev-certs https --trust; sudo bash \"./.devcontainer/install-ffmpeg.sh\"", - // reads the extensions list and installs them - "postAttachCommand": "cat .vscode/extensions.json | jq -r .recommendations[] | xargs -n 1 code --install-extension", + // The previous way of installing extensions via the vs command dont work on selfhosted devcontainers + "customizations": { + "vscode": { + "extensions": [ + "ms-dotnettools.csharp", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "ms-dotnettools.vscode-dotnet-runtime", + "ms-dotnettools.csdevkit", + "alexcvzz.vscode-sqlite", + "streetsidesoftware.code-spell-checker", + "eamodio.gitlens", + "redhat.vscode-xml" + ] + } + }, "features": { "ghcr.io/devcontainers/features/dotnet:2": { "version": "none", - "dotnetRuntimeVersions": "9.0", - "aspNetCoreRuntimeVersions": "9.0" + "dotnetRuntimeVersions": "10.0", + "aspNetCoreRuntimeVersions": "10.0" }, "ghcr.io/devcontainers-extra/features/apt-packages:1": { "preserve_apt_list": false, diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 9072fa9f9..66fa73d25 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -28,13 +28,13 @@ jobs: dotnet-version: '10.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml index 23a82a1b2..8e3717b33 100644 --- a/.github/workflows/ci-compat.yml +++ b/.github/workflows/ci-compat.yml @@ -1,6 +1,6 @@ name: ABI Compatibility on: - pull_request_target: + pull_request: permissions: {} @@ -77,7 +77,7 @@ jobs: pull-requests: write # to create or update comment (peter-evans/create-or-update-comment) name: ABI - Difference - if: ${{ github.event_name == 'pull_request_target' }} + if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest needs: - abi-head diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml index 08eedd54f..3d04ac5e0 100644 --- a/.github/workflows/ci-openapi.yml +++ b/.github/workflows/ci-openapi.yml @@ -5,7 +5,7 @@ on: - master tags: - 'v*' - pull_request_target: + pull_request: permissions: {} @@ -78,7 +78,7 @@ jobs: pull-requests: write name: OpenAPI - Difference - if: ${{ github.event_name == 'pull_request_target' }} + if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest needs: - openapi-head @@ -109,7 +109,7 @@ jobs: publish-unstable: name: OpenAPI - Publish Unstable Spec - if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }} + if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }} runs-on: ubuntu-latest needs: - openapi-head diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 2c4efcc8c..2adb8f101 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -4,7 +4,7 @@ on: types: - created - edited - pull_request_target: + pull_request: types: - labeled - synchronize diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml index cb535297e..339fcf569 100644 --- a/.github/workflows/issue-stale.yml +++ b/.github/workflows/issue-stale.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml index 7b29d3c81..9a9f3214a 100644 --- a/.github/workflows/project-automation.yml +++ b/.github/workflows/project-automation.yml @@ -4,7 +4,7 @@ on: push: branches: - master - pull_request_target: + pull_request: issue_comment: permissions: {} diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml index e6a9bf0ca..b003636a6 100644 --- a/.github/workflows/pull-request-conflict.yml +++ b/.github/workflows/pull-request-conflict.yml @@ -4,7 +4,7 @@ on: push: branches: - master - pull_request_target: + pull_request: issue_comment: permissions: {} @@ -16,7 +16,7 @@ jobs: steps: - name: Apply label uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 - if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} + if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}} with: dirtyLabel: 'merge conflict' commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml index 0d74e643e..e114276c2 100644 --- a/.github/workflows/pull-request-stale.yaml +++ b/.github/workflows/pull-request-stale.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1770db60b..cb7d3fbbc 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -287,3 +287,4 @@ - [Martin Reuter](https://github.com/reuterma24) - [Michael McElroy](https://github.com/mcmcelro) - [Soumyadip Auddy](https://github.com/SoumyadipAuddy) + - [DerMaddis](https://github.com/dermaddis) diff --git a/Directory.Packages.props b/Directory.Packages.props index 668b60109..a520b87e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ </PropertyGroup> <!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.--> <ItemGroup Label="Package Dependencies"> - <PackageVersion Include="AsyncKeyedLock" Version="8.0.1" /> + <PackageVersion Include="AsyncKeyedLock" Version="8.0.2" /> <PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" /> <PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" /> <PackageVersion Include="AutoFixture" Version="4.18.1" /> @@ -13,7 +13,7 @@ <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" /> <PackageVersion Include="CommandLineParser" Version="2.9.1" /> - <PackageVersion Include="coverlet.collector" Version="6.0.4" /> + <PackageVersion Include="coverlet.collector" Version="8.0.0" /> <PackageVersion Include="Diacritics" Version="4.1.4" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> @@ -26,27 +26,27 @@ <PackageVersion Include="libse" Version="4.0.12" /> <PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.2" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.3" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.3" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.0.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" /> - <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.2" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.2" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.2" /> - <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.2" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.3" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.3" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.3" /> + <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.3" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" /> <PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="Morestachio" Version="5.0.1.631" /> @@ -77,10 +77,10 @@ <PackageVersion Include="Svg.Skia" Version="3.4.1" /> <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.9.0" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="7.3.2" /> - <PackageVersion Include="System.Text.Json" Version="10.0.2" /> + <PackageVersion Include="System.Text.Json" Version="10.0.3" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" /> <PackageVersion Include="z440.atl.core" Version="7.11.0" /> - <PackageVersion Include="TMDbLib" Version="2.3.0" /> + <PackageVersion Include="TMDbLib" Version="3.0.0" /> <PackageVersion Include="UTF.Unknown" Version="2.6.0" /> <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" /> diff --git a/Emby.Naming/TV/TvParserHelpers.cs b/Emby.Naming/TV/TvParserHelpers.cs index 029917858..706251f29 100644 --- a/Emby.Naming/TV/TvParserHelpers.cs +++ b/Emby.Naming/TV/TvParserHelpers.cs @@ -18,7 +18,7 @@ public static class TvParserHelpers /// <param name="status">The status string.</param> /// <param name="enumValue">The <see cref="SeriesStatus"/>.</param> /// <returns>Returns true if parsing was successful.</returns> - public static bool TryParseSeriesStatus(string status, out SeriesStatus? enumValue) + public static bool TryParseSeriesStatus(string? status, out SeriesStatus? enumValue) { if (Enum.TryParse(status, true, out SeriesStatus seriesStatus)) { diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs index 4874eca8e..996cd1b3c 100644 --- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs @@ -267,22 +267,24 @@ namespace Emby.Server.Implementations.Images { var image = item.GetImageInfo(type, 0); - if (image is not null) + if (image is null) { - if (!image.IsLocalFile) - { - return false; - } + return GetItemsWithImages(item).Count is not 0; + } - if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path)) - { - return false; - } + if (!image.IsLocalFile) + { + return false; + } - if (!HasChangedByDate(item, image)) - { - return false; - } + if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path)) + { + return false; + } + + if (!HasChangedByDate(item, image)) + { + return false; } return true; diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 3d598c491..cb11cc089 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -3,7 +3,7 @@ "Playlists": "Плэй-лісты", "Latest": "Апошняе", "LabelIpAddressValue": "IP-адрас: {0}", - "ItemAddedWithName": "{0} даданы ў бібліятэку", + "ItemAddedWithName": "{0} дададзены ў бібліятэку", "MessageApplicationUpdated": "Сервер Jellyfin абноўлены", "NotificationOptionApplicationUpdateInstalled": "Абнаўленне праграмы ўсталявана", "PluginInstalledWithName": "{0} быў усталяваны", @@ -14,7 +14,7 @@ "Channels": "Каналы", "ChapterNameValue": "Раздзел {0}", "Collections": "Калекцыі", - "Default": "Па змаўчанні", + "Default": "Прадвызначана", "FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}", "Folders": "Папкі", "Favorites": "Абранае", @@ -81,8 +81,8 @@ "NotificationOptionInstallationFailed": "Збой усталёўкі", "NewVersionIsAvailable": "Новая версія сервера Jellyfin даступная для cпампоўкі.", "NotificationOptionCameraImageUploaded": "Выява камеры запампавана", - "NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыё спынена", - "NotificationOptionAudioPlayback": "Прайграванне аўдыё пачалося", + "NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыя спынена", + "NotificationOptionAudioPlayback": "Прайграванне аўдыя пачалося", "NotificationOptionNewLibraryContent": "Дададзены новы кантэнт", "NotificationOptionPluginError": "Збой плагіна", "NotificationOptionPluginUninstalled": "Плагін выдалены", diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 82cc1857b..1e7279be8 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -104,7 +104,7 @@ "TaskCleanLogsDescription": "Esborra els registres que tinguin més de {0} dies.", "TaskCleanLogs": "Neteja dels registres", "TaskRefreshLibraryDescription": "Escaneja les mediateques, a la cerca de fitxers nous i refresca les metadades.", - "TaskRefreshLibrary": "Escaneig de les mediateques", + "TaskRefreshLibrary": "Escaneja la mediateca", "TaskRefreshChapterImagesDescription": "Creació de les miniatures dels vídeos que tinguin capítols.", "TaskRefreshChapterImages": "Extracció de les imatges dels capítols", "TaskCleanCacheDescription": "Eliminació de la memòria cau no necessària per al servidor.", diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index ff60e6127..f0c4b5027 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -61,7 +61,7 @@ "NotificationOptionVideoPlayback": "Riproduzione video iniziata", "NotificationOptionVideoPlaybackStopped": "Riproduzione video interrotta", "Photos": "Foto", - "Playlists": "Playlist", + "Playlists": "Scalette", "Plugin": "Plugin", "PluginInstalledWithName": "{0} è stato installato", "PluginUninstalledWithName": "{0} è stato disinstallato", @@ -114,20 +114,20 @@ "TasksLibraryCategory": "Libreria", "TasksMaintenanceCategory": "Manutenzione", "TaskCleanActivityLog": "Attività di Registro Completate", - "TaskCleanActivityLogDescription": "Elimina le voci del registro delle attività più vecchie dell’età configurata.", + "TaskCleanActivityLogDescription": "Elimina le voci del registro delle attività più vecchie dell'età configurata.", "Undefined": "Non specificato", "Forced": "Forzato", "Default": "Predefinito", "TaskOptimizeDatabaseDescription": "Compatta database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altre modifiche inerenti il database potrebbe aumentarne le prestazioni.", "TaskOptimizeDatabase": "Ottimizza database", "TaskKeyframeExtractor": "Estrattore di Keyframe", - "TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori playlist HLS. Questa procedura potrebbe richiedere molto tempo.", + "TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori scalette HLS. Questa procedura potrebbe richiedere molto tempo.", "External": "Esterno", "HearingImpaired": "Non udenti", "TaskRefreshTrickplayImages": "Genera immagini Trickplay", "TaskRefreshTrickplayImagesDescription": "Crea anteprime trickplay per i video nelle librerie abilitate.", - "TaskCleanCollectionsAndPlaylists": "Ripulire le collezioni e le playlist", - "TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle collezioni e dalle playlist che non esistono più.", + "TaskCleanCollectionsAndPlaylists": "Ripulisci le collezioni e le scalette", + "TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle collezioni e dalle scalette che non esistono più.", "TaskAudioNormalization": "Normalizzazione dell'audio", "TaskAudioNormalizationDescription": "Scansiona i file alla ricerca dei dati per la normalizzazione dell'audio.", "TaskDownloadMissingLyricsDescription": "Scarica testi per le canzoni", diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json index d564d54ce..bdca8ae1c 100644 --- a/Emby.Server.Implementations/Localization/Core/ja.json +++ b/Emby.Server.Implementations/Localization/Core/ja.json @@ -43,32 +43,32 @@ "NameInstallFailed": "{0}のインストールに失敗しました", "NameSeasonNumber": "シーズン {0}", "NameSeasonUnknown": "シーズン不明", - "NewVersionIsAvailable": "新しいバージョンの Jellyfin Server がダウンロード可能です。", + "NewVersionIsAvailable": "新しいバージョンの Jellyfin Server がダウンロードできます。", "NotificationOptionApplicationUpdateAvailable": "アプリケーションの更新があります", "NotificationOptionApplicationUpdateInstalled": "アプリケーションは最新です", "NotificationOptionAudioPlayback": "オーディオの再生を開始", - "NotificationOptionAudioPlaybackStopped": "オーディオの再生をストップしました", + "NotificationOptionAudioPlaybackStopped": "オーディオの再生を停止", "NotificationOptionCameraImageUploaded": "カメライメージがアップロードされました", "NotificationOptionInstallationFailed": "インストール失敗", "NotificationOptionNewLibraryContent": "新しいコンテンツを追加しました", "NotificationOptionPluginError": "プラグインに障害が発生しました", - "NotificationOptionPluginInstalled": "プラグインがインストールされました", - "NotificationOptionPluginUninstalled": "プラグインがアンインストールされました", + "NotificationOptionPluginInstalled": "プラグインをインストールしました", + "NotificationOptionPluginUninstalled": "プラグインをアンインストールしました", "NotificationOptionPluginUpdateInstalled": "プラグインのアップデートをインストールしました", "NotificationOptionServerRestartRequired": "サーバーを再起動してください", "NotificationOptionTaskFailed": "スケジュールされていたタスクの失敗", "NotificationOptionUserLockedOut": "ユーザーはロックされています", - "NotificationOptionVideoPlayback": "ビデオの再生を開始しました", - "NotificationOptionVideoPlaybackStopped": "ビデオを停止しました", + "NotificationOptionVideoPlayback": "ビデオの再生を開始", + "NotificationOptionVideoPlaybackStopped": "ビデオの再生を停止", "Photos": "フォト", "Playlists": "プレイリスト", "Plugin": "プラグイン", - "PluginInstalledWithName": "{0} がインストールされました", - "PluginUninstalledWithName": "{0} がアンインストールされました", - "PluginUpdatedWithName": "{0} が更新されました", + "PluginInstalledWithName": "{0} をインストールしました", + "PluginUninstalledWithName": "{0} をアンインストールしました", + "PluginUpdatedWithName": "{0} を更新しました", "ProviderValue": "プロバイダ: {0}", "ScheduledTaskFailedWithName": "{0} が失敗しました", - "ScheduledTaskStartedWithName": "{0} が開始されました", + "ScheduledTaskStartedWithName": "{0} を開始", "ServerNameNeedsToBeRestarted": "{0} を再起動してください", "Shows": "番組", "Songs": "曲", diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 1577c5c9c..409414139 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -198,17 +198,22 @@ namespace Emby.Server.Implementations.Playlists return Playlist.GetPlaylistItems(items, user, options); } - public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId) + public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, int? position, Guid userId) { var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId); - return AddToPlaylistInternal(playlistId, itemIds, user, new DtoOptions(false) - { - EnableImages = true - }); + return AddToPlaylistInternal( + playlistId, + itemIds, + user, + new DtoOptions(false) + { + EnableImages = true + }, + position); } - private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options) + private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options, int? position = null) { // Retrieve the existing playlist var playlist = _libraryManager.GetItemById(playlistId) as Playlist @@ -243,7 +248,30 @@ namespace Emby.Server.Implementations.Playlists } // Update the playlist in the repository - playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd]; + if (position.HasValue) + { + if (position.Value <= 0) + { + playlist.LinkedChildren = [.. childrenToAdd, .. playlist.LinkedChildren]; + } + else if (position.Value >= playlist.LinkedChildren.Length) + { + playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd]; + } + else + { + playlist.LinkedChildren = [ + .. playlist.LinkedChildren[0..position.Value], + .. childrenToAdd, + .. playlist.LinkedChildren[position.Value..playlist.LinkedChildren.Length] + ]; + } + } + else + { + playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd]; + } + playlist.DateLastMediaAdded = DateTime.UtcNow; await UpdatePlaylistInternal(playlist).ConfigureAwait(false); diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs index 59e6fd779..967918093 100644 --- a/Jellyfin.Api/Controllers/PlaylistsController.cs +++ b/Jellyfin.Api/Controllers/PlaylistsController.cs @@ -359,6 +359,7 @@ public class PlaylistsController : BaseJellyfinApiController /// </summary> /// <param name="playlistId">The playlist id.</param> /// <param name="ids">Item id, comma delimited.</param> + /// <param name="position">Optional. 0-based index where to place the items or at the end if <c>null</c>.</param> /// <param name="userId">The userId.</param> /// <response code="204">Items added to playlist.</response> /// <response code="403">Access forbidden.</response> @@ -371,6 +372,7 @@ public class PlaylistsController : BaseJellyfinApiController public async Task<ActionResult> AddItemToPlaylist( [FromRoute, Required] Guid playlistId, [FromQuery, ModelBinder(typeof(CommaDelimitedCollectionModelBinder))] Guid[] ids, + [FromQuery] int? position, [FromQuery] Guid? userId) { userId = RequestHelpers.GetUserId(User, userId); @@ -388,7 +390,7 @@ public class PlaylistsController : BaseJellyfinApiController return Forbid(); } - await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false); + await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, position, userId.Value).ConfigureAwait(false); return NoContent(); } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs index 4b1e53a35..70761fa7d 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs @@ -1163,7 +1163,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine Item = null!, ProviderId = e[0], ProviderValue = string.Join('|', e.Skip(1)) - }).ToArray(); + }) + .DistinctBy(e => e.ProviderId) + .ToArray(); } if (reader.TryGetString(index++, out var imageInfos)) diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index d2a3290c4..2ecb6cbdf 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -452,6 +452,7 @@ namespace MediaBrowser.Controller.Entities // That's all the new and changed ones - now see if any have been removed and need cleanup var itemsRemoved = currentChildren.Values.Except(validChildren).ToList(); var shouldRemove = !IsRoot || allowRemoveRoot; + var actuallyRemoved = new List<BaseItem>(); // If it's an AggregateFolder, don't remove if (shouldRemove && itemsRemoved.Count > 0) { @@ -467,6 +468,7 @@ namespace MediaBrowser.Controller.Entities { Logger.LogDebug("Removed item: {Path}", item.Path); + actuallyRemoved.Add(item); item.SetParent(null); LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false); } @@ -477,6 +479,20 @@ namespace MediaBrowser.Controller.Entities { LibraryManager.CreateItems(newItems, this, cancellationToken); } + + // After removing items, reattach any detached user data to remaining children + // that share the same user data keys (eg. same episode replaced with a new file). + if (actuallyRemoved.Count > 0) + { + var removedKeys = actuallyRemoved.SelectMany(i => i.GetUserDataKeys()).ToHashSet(); + foreach (var child in validChildren) + { + if (child.GetUserDataKeys().Any(removedKeys.Contains)) + { + await child.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false); + } + } + } } else { diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 6396631f9..6a26ecaeb 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -451,7 +451,7 @@ namespace MediaBrowser.Controller.Entities.TV if (!currentSeasonNumber.HasValue && !seasonNumber.HasValue && parentSeason.LocationType == LocationType.Virtual) { - return true; + return episodeItem.Season is null or { LocationType: LocationType.Virtual }; } var season = episodeItem.Season; diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs index 497c4a511..92aa92396 100644 --- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs +++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs @@ -61,9 +61,10 @@ namespace MediaBrowser.Controller.Playlists /// </summary> /// <param name="playlistId">The playlist identifier.</param> /// <param name="itemIds">The item ids.</param> + /// <param name="position">Optional. 0-based index where to place the items or at the end if null.</param> /// <param name="userId">The user identifier.</param> /// <returns>Task.</returns> - Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId); + Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, int? position, Guid userId); /// <summary> /// Removes from playlist. diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 50f7716d8..dbe532289 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -83,6 +83,7 @@ namespace MediaBrowser.MediaEncoding.Probing "Smith/Kotzen", "We;Na", "LSR/CITY", + "Kairon; IRSE!", }; /// <summary> diff --git a/MediaBrowser.Model/Providers/RemoteSearchResult.cs b/MediaBrowser.Model/Providers/RemoteSearchResult.cs index a29e7ad1c..7d3b5e4ab 100644 --- a/MediaBrowser.Model/Providers/RemoteSearchResult.cs +++ b/MediaBrowser.Model/Providers/RemoteSearchResult.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -19,7 +18,7 @@ namespace MediaBrowser.Model.Providers /// Gets or sets the name. /// </summary> /// <value>The name.</value> - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the provider ids. @@ -41,13 +40,13 @@ namespace MediaBrowser.Model.Providers public DateTime? PremiereDate { get; set; } - public string ImageUrl { get; set; } + public string? ImageUrl { get; set; } - public string SearchProviderName { get; set; } + public string? SearchProviderName { get; set; } - public string Overview { get; set; } + public string? Overview { get; set; } - public RemoteSearchResult AlbumArtist { get; set; } + public RemoteSearchResult? AlbumArtist { get; set; } public RemoteSearchResult[] Artists { get; set; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs index 450ee2a33..3eacc4f0f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs @@ -33,7 +33,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Api /// <returns>The image portion of the TMDb client configuration.</returns> [HttpGet("ClientConfiguration")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ConfigImageTypes> TmdbClientConfiguration() + public async Task<ConfigImageTypes?> TmdbClientConfiguration() { return (await _tmdbClientManager.GetClientConfiguration().ConfigureAwait(false)).Images; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 02818a0e2..78be5804e 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -75,10 +75,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets var posters = collection.Images.Posters; var backdrops = collection.Images.Backdrops; - var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count); + var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0); - remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + if (posters is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); + } + + if (backdrops is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + } return remoteImages; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index 34c9abae1..a7bba2d53 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -67,10 +67,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets result.SetProviderId(MetadataProvider.Tmdb, collection.Id.ToString(CultureInfo.InvariantCulture)); - return new[] { result }; + return [result]; } var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + if (collectionSearchResults is null) + { + return []; + } var collections = new RemoteSearchResult[collectionSearchResults.Count]; for (var i = 0; i < collectionSearchResults.Count; i++) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index fcc357410..714c57d36 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -79,7 +79,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movieTmdbId <= 0) { - return Enumerable.Empty<RemoteImageInfo>(); + return []; } // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here @@ -89,17 +89,28 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (movie?.Images is null) { - return Enumerable.Empty<RemoteImageInfo>(); + return []; } var posters = movie.Images.Posters; var backdrops = movie.Images.Backdrops; var logos = movie.Images.Logos; - var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count); + var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0 + logos?.Count ?? 0); - remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); + if (posters is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); + } + + if (backdrops is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + } + + if (logos is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); + } return remoteImages; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 2beb34e43..ff584ba1d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -15,6 +15,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; using TMDbLib.Objects.Find; +using TMDbLib.Objects.General; using TMDbLib.Objects.Search; namespace MediaBrowser.Providers.Plugins.Tmdb.Movies @@ -84,7 +85,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies remoteResult.SetProviderId(MetadataProvider.Tmdb, movie.Id.ToString(CultureInfo.InvariantCulture)); remoteResult.TrySetProviderId(MetadataProvider.Imdb, movie.ImdbId); - return new[] { remoteResult }; + return [remoteResult]; } } @@ -118,6 +119,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies .ConfigureAwait(false); } + if (movieResults is null) + { + return []; + } + var len = movieResults.Count; var remoteSearchResults = new RemoteSearchResult[len]; for (var i = 0; i < len; i++) @@ -158,7 +164,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (searchResults.Count > 0) + if (searchResults?.Count > 0) { tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -167,7 +173,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId)) { var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (movieResultFromImdbId?.MovieResults.Count > 0) + if (movieResultFromImdbId?.MovieResults?.Count > 0) { tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -193,7 +199,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies OriginalTitle = movieResult.OriginalTitle, Overview = movieResult.Overview?.Replace("\n\n", "\n", StringComparison.InvariantCulture), Tagline = movieResult.Tagline, - ProductionLocations = movieResult.ProductionCountries.Select(pc => pc.Name).ToArray() + ProductionLocations = movieResult.ProductionCountries?.Select(pc => pc.Name).ToArray() ?? Array.Empty<string>() }; var metadataResult = new MetadataResult<Movie> { @@ -218,14 +224,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var ourRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, info.MetadataCountryCode, StringComparison.OrdinalIgnoreCase)); - if (ourRelease is not null) + if (ourRelease?.Certification is not null) { - movie.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Certification); + movie.OfficialRating = TmdbUtils.BuildParentalRating(info.MetadataCountryCode, ourRelease.Certification); } else { var usRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase)); - if (usRelease is not null) + if (usRelease?.Certification is not null) { movie.OfficialRating = usRelease.Certification; } @@ -242,16 +248,23 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var genres = movieResult.Genres; - foreach (var genre in genres.Select(g => g.Name).Trimmed()) + if (genres is not null) { - movie.AddGenre(genre); + foreach (var genre in genres.Select(g => g.Name).Trimmed()) + { + movie.AddGenre(genre); + } } if (movieResult.Keywords?.Keywords is not null) { - for (var i = 0; i < movieResult.Keywords.Keywords.Count; i++) + foreach (var keyword in movieResult.Keywords.Keywords) { - movie.AddTag(movieResult.Keywords.Keywords[i].Name); + var name = keyword.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + movie.AddTag(name); + } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 4b32d0f6b..64ab98b26 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -56,13 +56,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People } result.SetProviderId(MetadataProvider.Tmdb, personResult.Id.ToString(CultureInfo.InvariantCulture)); - result.TrySetProviderId(MetadataProvider.Imdb, personResult.ExternalIds.ImdbId); + result.TrySetProviderId(MetadataProvider.Imdb, personResult.ExternalIds?.ImdbId); - return new[] { result }; + return [result]; } } var personSearchResult = await _tmdbClientManager.SearchPersonAsync(searchInfo.Name, cancellationToken).ConfigureAwait(false); + if (personSearchResult is null) + { + return []; + } var remoteSearchResults = new RemoteSearchResult[personSearchResult.Count]; for (var i = 0; i < personSearchResult.Count; i++) @@ -91,7 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People if (personTmdbId <= 0) { var personSearchResults = await _tmdbClientManager.SearchPersonAsync(info.Name, cancellationToken).ConfigureAwait(false); - if (personSearchResults.Count > 0) + if (personSearchResults?.Count > 0) { personTmdbId = personSearchResults[0].Id; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 0905a3bdc..1eb522137 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -76,7 +76,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV result.Item.Name = seasonResult.Name; } - result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds.TvdbId); + result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds?.TvdbId); // TODO why was this disabled? var credits = seasonResult.Credits; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index 5cba84dcb..f2e7d0c6e 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -79,11 +79,22 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var posters = series.Images.Posters; var backdrops = series.Images.Backdrops; var logos = series.Images.Logos; - var remoteImages = new List<RemoteImageInfo>(posters.Count + backdrops.Count + logos.Count); + var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0 + logos?.Count ?? 0); - remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); - remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); + if (posters is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertPostersToRemoteImageInfo(posters, language)); + } + + if (backdrops is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertBackdropsToRemoteImageInfo(backdrops, language)); + } + + if (logos is not null) + { + remoteImages.AddRange(_tmdbClientManager.ConvertLogosToRemoteImageInfo(logos, language)); + } return remoteImages; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 82d4e5838..7e36c1e20 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -112,6 +112,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken: cancellationToken) .ConfigureAwait(false); + if (tvSearchResults is null) + { + return []; + } var remoteResults = new RemoteSearchResult[tvSearchResults.Count]; for (var i = 0; i < tvSearchResults.Count; i++) @@ -141,6 +145,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } remoteResult.PremiereDate = series.FirstAirDate?.ToUniversalTime(); + remoteResult.ProductionYear = series.FirstAirDate?.Year; return remoteResult; } @@ -157,6 +162,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV remoteResult.SetProviderId(MetadataProvider.Tmdb, series.Id.ToString(CultureInfo.InvariantCulture)); remoteResult.PremiereDate = series.FirstAirDate?.ToUniversalTime(); + remoteResult.ProductionYear = series.FirstAirDate?.Year; return remoteResult; } @@ -174,7 +180,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Imdb, out var imdbId)) { var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (searchResult?.TvResults.Count > 0) + if (searchResult?.TvResults?.Count > 0) { tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -183,7 +189,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId)) { var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); - if (searchResult?.TvResults.Count > 0) + if (searchResult?.TvResults?.Count > 0) { tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -198,7 +204,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var cleanedName = TmdbUtils.CleanName(parsedName.Name); var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false); - if (searchResults.Count > 0) + if (searchResults?.Count > 0) { tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture); } @@ -262,15 +268,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV if (seriesResult.Keywords?.Results is not null) { - for (var i = 0; i < seriesResult.Keywords.Results.Count; i++) + foreach (var result in seriesResult.Keywords.Results) { - series.AddTag(seriesResult.Keywords.Results[i].Name); + var name = result.Name; + if (!string.IsNullOrWhiteSpace(name)) + { + series.AddTag(name); + } } } series.HomePageUrl = seriesResult.Homepage; - series.RunTimeTicks = seriesResult.EpisodeRunTime.Select(i => TimeSpan.FromMinutes(i).Ticks).FirstOrDefault(); + series.RunTimeTicks = seriesResult.EpisodeRunTime?.Select(i => TimeSpan.FromMinutes(i).Ticks).FirstOrDefault(); if (Emby.Naming.TV.TvParserHelpers.TryParseSeriesStatus(seriesResult.Status, out var seriesStatus)) { @@ -279,6 +289,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.EndDate = seriesResult.LastAirDate; series.PremiereDate = seriesResult.FirstAirDate; + series.ProductionYear = seriesResult.FirstAirDate?.Year; var ids = seriesResult.ExternalIds; if (ids is not null) @@ -288,21 +299,21 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV series.TrySetProviderId(MetadataProvider.Tvdb, ids.TvdbId); } - var contentRatings = seriesResult.ContentRatings.Results ?? new List<ContentRating>(); + var contentRatings = seriesResult.ContentRatings?.Results ?? new List<ContentRating>(); var ourRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, preferredCountryCode, StringComparison.OrdinalIgnoreCase)); var usRelease = contentRatings.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase)); var minimumRelease = contentRatings.FirstOrDefault(); - if (ourRelease is not null) + if (ourRelease?.Rating is not null) { - series.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Rating); + series.OfficialRating = TmdbUtils.BuildParentalRating(preferredCountryCode, ourRelease.Rating); } - else if (usRelease is not null) + else if (usRelease?.Rating is not null) { series.OfficialRating = usRelease.Rating; } - else if (minimumRelease is not null) + else if (minimumRelease?.Rating is not null) { series.OfficialRating = minimumRelease.Rating; } @@ -347,7 +358,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV Role = actor.Character?.Trim() ?? string.Empty, Type = PersonKind.Actor, SortOrder = actor.Order, - ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath) + // NOTE: Null values are filtered out above + ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath!) }; if (actor.Id > 0) @@ -388,7 +400,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV Name = crewMember.Name.Trim(), Role = crewMember.Job?.Trim() ?? string.Empty, Type = entry.PersonType, - ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath) + // NOTE: Null values are filtered out above + ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath!) }; if (crewMember.Id > 0) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index fedf34598..274db347b 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Globalization; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Enums; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; @@ -195,7 +194,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); var series = await GetSeriesAsync(tvShowId, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); - var episodeGroupId = series?.EpisodeGroups.Results.Find(g => g.Type == groupType)?.Id; + var episodeGroupId = series?.EpisodeGroups?.Results?.Find(g => g.Type == groupType)?.Id; if (episodeGroupId is null) { @@ -263,7 +262,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv episode information or null if not found.</returns> - public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) + public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, long episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken) { var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}"; if (_memoryCache.TryGetValue(key, out TvEpisode? episode)) @@ -276,9 +275,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false); if (group is not null) { - var season = group.Groups.Find(s => s.Order == seasonNumber); + var season = group.Groups?.Find(s => s.Order == seasonNumber); // Episode order starts at 0 - var ep = season?.Episodes.Find(e => e.Order == episodeNumber - 1); + var ep = season?.Episodes?.Find(e => e.Order == episodeNumber - 1); if (ep is not null) { seasonNumber = ep.SeasonNumber; @@ -382,7 +381,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="year">The year the tv show first aired.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb tv show information.</returns> - public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, string? countryCode, int year = 0, CancellationToken cancellationToken = default) + public async Task<IReadOnlyList<SearchTv>?> SearchSeriesAsync(string name, string language, string? countryCode, int year = 0, CancellationToken cancellationToken = default) { var key = $"searchseries-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv>? series) && series is not null) @@ -396,12 +395,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken) .ConfigureAwait(false); - if (searchResults.Results.Count > 0) + if (searchResults?.Results?.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } - return searchResults.Results; + return searchResults?.Results; } /// <summary> @@ -410,7 +409,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="name">The name of the person.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb person information.</returns> - public async Task<IReadOnlyList<SearchPerson>> SearchPersonAsync(string name, CancellationToken cancellationToken) + public async Task<IReadOnlyList<SearchPerson>?> SearchPersonAsync(string name, CancellationToken cancellationToken) { var key = $"searchperson-{name}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchPerson>? person) && person is not null) @@ -424,12 +423,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb .SearchPersonAsync(name, includeAdult: Plugin.Instance.Configuration.IncludeAdult, cancellationToken: cancellationToken) .ConfigureAwait(false); - if (searchResults.Results.Count > 0) + if (searchResults?.Results?.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } - return searchResults.Results; + return searchResults?.Results; } /// <summary> @@ -439,7 +438,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="language">The movie's language.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb movie information.</returns> - public Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, string language, CancellationToken cancellationToken) + public Task<IReadOnlyList<SearchMovie>?> SearchMovieAsync(string name, string language, CancellationToken cancellationToken) { return SearchMovieAsync(name, 0, language, null, cancellationToken); } @@ -453,7 +452,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb movie information.</returns> - public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, string? countryCode, CancellationToken cancellationToken) + public async Task<IReadOnlyList<SearchMovie>?> SearchMovieAsync(string name, int year, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchMovie>? movies) && movies is not null) @@ -467,12 +466,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken) .ConfigureAwait(false); - if (searchResults.Results.Count > 0) + if (searchResults?.Results?.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } - return searchResults.Results; + return searchResults?.Results; } /// <summary> @@ -483,7 +482,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="countryCode">The country code, ISO 3166-1.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>The TMDb collection information.</returns> - public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, string? countryCode, CancellationToken cancellationToken) + public async Task<IReadOnlyList<SearchCollection>?> SearchCollectionAsync(string name, string language, string? countryCode, CancellationToken cancellationToken) { var key = $"collectionsearch-{name}-{language}"; if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection>? collections) && collections is not null) @@ -497,12 +496,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb .SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken: cancellationToken) .ConfigureAwait(false); - if (searchResults.Results.Count > 0) + if (searchResults?.Results?.Count > 0) { _memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours)); } - return searchResults.Results; + return searchResults?.Results; } /// <summary> @@ -511,14 +510,17 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="size">The image size to fetch.</param> /// <param name="path">The relative URL of the image.</param> /// <returns>The absolute URL.</returns> - private string? GetUrl(string? size, string path) + private string? GetUrl(string? size, string? path) { if (string.IsNullOrEmpty(path)) { return null; } - return _tmDbClient.GetImageUrl(size, path, true).ToString(); + // Use "original" as default size if size is null or empty to prevent malformed URLs + var imageSize = string.IsNullOrEmpty(size) ? "original" : size; + + return _tmDbClient.GetImageUrl(imageSize, path, true).ToString(); } /// <summary> @@ -526,7 +528,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="posterPath">The relative URL of the poster.</param> /// <returns>The absolute URL.</returns> - public string? GetPosterUrl(string posterPath) + public string? GetPosterUrl(string? posterPath) { return GetUrl(Plugin.Instance.Configuration.PosterSize, posterPath); } @@ -536,7 +538,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// </summary> /// <param name="actorProfilePath">The relative URL of the profile image.</param> /// <returns>The absolute URL.</returns> - public string? GetProfileUrl(string actorProfilePath) + public string? GetProfileUrl(string? actorProfilePath) { return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath); } @@ -639,30 +641,44 @@ namespace MediaBrowser.Providers.Plugins.Tmdb private static void ValidatePreferences(TMDbConfig config) { var imageConfig = config.Images; + if (imageConfig is null) + { + return; + } var pluginConfig = Plugin.Instance.Configuration; - if (!imageConfig.PosterSizes.Contains(pluginConfig.PosterSize)) + if (imageConfig.PosterSizes is not null + && pluginConfig.PosterSize is not null + && !imageConfig.PosterSizes.Contains(pluginConfig.PosterSize)) { pluginConfig.PosterSize = imageConfig.PosterSizes[^1]; } - if (!imageConfig.BackdropSizes.Contains(pluginConfig.BackdropSize)) + if (imageConfig.BackdropSizes is not null + && pluginConfig.BackdropSize is not null + && !imageConfig.BackdropSizes.Contains(pluginConfig.BackdropSize)) { pluginConfig.BackdropSize = imageConfig.BackdropSizes[^1]; } - if (!imageConfig.LogoSizes.Contains(pluginConfig.LogoSize)) + if (imageConfig.LogoSizes is not null + && pluginConfig.LogoSize is not null + && !imageConfig.LogoSizes.Contains(pluginConfig.LogoSize)) { pluginConfig.LogoSize = imageConfig.LogoSizes[^1]; } - if (!imageConfig.ProfileSizes.Contains(pluginConfig.ProfileSize)) + if (imageConfig.ProfileSizes is not null + && pluginConfig.ProfileSize is not null + && !imageConfig.ProfileSizes.Contains(pluginConfig.ProfileSize)) { pluginConfig.ProfileSize = imageConfig.ProfileSizes[^1]; } - if (!imageConfig.StillSizes.Contains(pluginConfig.StillSize)) + if (imageConfig.StillSizes is not null + && pluginConfig.StillSize is not null + && !imageConfig.StillSizes.Contains(pluginConfig.StillSize)) { pluginConfig.StillSize = imageConfig.StillSizes[^1]; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 0944b557e..39c0497be 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -69,20 +69,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <returns>The Jellyfin person type.</returns> public static PersonKind MapCrewToPersonType(Crew crew) { - if (crew.Department.Equals("directing", StringComparison.OrdinalIgnoreCase) - && crew.Job.Equals("director", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(crew.Department, "directing", StringComparison.OrdinalIgnoreCase) + && string.Equals(crew.Job, "director", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Director; } - if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase) - && crew.Job.Equals("producer", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(crew.Department, "production", StringComparison.OrdinalIgnoreCase) + && string.Equals(crew.Job, "producer", StringComparison.OrdinalIgnoreCase)) { return PersonKind.Producer; } - if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase) - && (crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase) || crew.Job.Equals("screenplay", StringComparison.OrdinalIgnoreCase))) + if (string.Equals(crew.Department, "writing", StringComparison.OrdinalIgnoreCase) + && (string.Equals(crew.Job, "writer", StringComparison.OrdinalIgnoreCase) || string.Equals(crew.Job, "screenplay", StringComparison.OrdinalIgnoreCase))) { return PersonKind.Writer; } @@ -97,9 +97,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <returns>A boolean indicating whether the video is a trailer.</returns> public static bool IsTrailerType(Video video) { - return video.Site.Equals("youtube", StringComparison.OrdinalIgnoreCase) - && (video.Type.Equals("trailer", StringComparison.OrdinalIgnoreCase) - || video.Type.Equals("teaser", StringComparison.OrdinalIgnoreCase)); + return string.Equals(video.Site, "youtube", StringComparison.OrdinalIgnoreCase) + && (string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase) + || string.Equals(video.Type, "teaser", StringComparison.OrdinalIgnoreCase)); } /// <summary> @@ -177,10 +177,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <param name="imageLanguage">The image's actual language code.</param> /// <param name="requestLanguage">The requested language code.</param> /// <returns>The language code.</returns> - public static string AdjustImageLanguage(string imageLanguage, string requestLanguage) + public static string AdjustImageLanguage(string? imageLanguage, string requestLanguage) { - if (!string.IsNullOrEmpty(imageLanguage) - && !string.IsNullOrEmpty(requestLanguage) + if (string.IsNullOrEmpty(imageLanguage)) + { + return string.Empty; + } + + if (!string.IsNullOrEmpty(requestLanguage) && requestLanguage.Length > 2 && imageLanguage.Length == 2 && requestLanguage.StartsWith(imageLanguage, StringComparison.OrdinalIgnoreCase)) diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index c3a6ddd6a..61a31fbfd 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -201,6 +202,26 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> false); } + private static bool NeedsVirtualSeason(Episode episode, HashSet<Guid> physicalSeasonIds, HashSet<string> physicalSeasonPaths) + { + // Episode has a known season number, needs a season + if (episode.ParentIndexNumber.HasValue) + { + return true; + } + + // Not yet processed + if (episode.SeasonId.IsEmpty()) + { + return false; + } + + // Episode has been processed, only needs a virtual season if it isn't + // already linked to a known physical season by ID or path + return !physicalSeasonIds.Contains(episode.SeasonId) + && !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty); + } + /// <summary> /// Creates seasons for all episodes if they don't exist. /// If no season number can be determined, a dummy season will be created. @@ -212,8 +233,20 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo> { var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season); var seasons = seriesChildren.OfType<Season>().ToList(); + + var physicalSeasonIds = seasons + .Where(e => e.LocationType != LocationType.Virtual) + .Select(e => e.Id) + .ToHashSet(); + + var physicalSeasonPathSet = seasons + .Where(e => e.LocationType != LocationType.Virtual && !string.IsNullOrEmpty(e.Path)) + .Select(e => e.Path) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var uniqueSeasonNumbers = seriesChildren .OfType<Episode>() + .Where(e => NeedsVirtualSeason(e, physicalSeasonIds, physicalSeasonPathSet)) .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null) .Distinct(); diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index 60df47113..c7e8319f5 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -131,7 +131,7 @@ namespace Jellyfin.Extensions /// </summary> /// <param name="values">The enumerable of strings to trim.</param> /// <returns>The enumeration of trimmed strings.</returns> - public static IEnumerable<string> Trimmed(this IEnumerable<string> values) + public static IEnumerable<string> Trimmed(this IEnumerable<string?> values) { return values.Select(i => (i ?? string.Empty).Trim()); } |
