aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.devcontainer/devcontainer.json24
-rw-r--r--.editorconfig7
-rw-r--r--.github/ISSUE_TEMPLATE/issue report.yml1
-rw-r--r--.github/workflows/ci-codeql-analysis.yml15
-rw-r--r--.github/workflows/ci-compat.yml26
-rw-r--r--.github/workflows/ci-openapi.yml113
-rw-r--r--.github/workflows/ci-tests.yml8
-rw-r--r--.github/workflows/commands.yml11
-rw-r--r--.github/workflows/issue-stale.yml2
-rw-r--r--.github/workflows/issue-template-check.yml7
-rw-r--r--.github/workflows/project-automation.yml3
-rw-r--r--.github/workflows/pull-request-conflict.yml4
-rw-r--r--.github/workflows/pull-request-stale.yaml2
-rw-r--r--.github/workflows/release-bump-version.yaml4
-rw-r--r--.vscode/launch.json6
-rw-r--r--CONTRIBUTORS.md6
-rw-r--r--Directory.Packages.props79
-rw-r--r--Emby.Naming/Common/NamingOptions.cs1
-rw-r--r--Emby.Naming/Emby.Naming.csproj2
-rw-r--r--Emby.Naming/TV/TvParserHelpers.cs2
-rw-r--r--Emby.Naming/Video/VideoListResolver.cs17
-rw-r--r--Emby.Photos/Emby.Photos.csproj2
-rw-r--r--Emby.Server.Implementations/Cryptography/CryptographyProvider.cs27
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs53
-rw-r--r--Emby.Server.Implementations/Emby.Server.Implementations.csproj3
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitor.cs6
-rw-r--r--Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs28
-rw-r--r--Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs8
-rw-r--r--Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs48
-rw-r--r--Emby.Server.Implementations/Library/IgnorePatterns.cs4
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs42
-rw-r--r--Emby.Server.Implementations/Library/PathExtensions.cs24
-rw-r--r--Emby.Server.Implementations/Localization/Core/af.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ar.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/be.json30
-rw-r--r--Emby.Server.Implementations/Localization/Core/bg-BG.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ca.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/cy.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/de.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-AR.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-MX.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/et.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/fa.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr-CA.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/gsw.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/he_IL.json28
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json51
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json20
-rw-r--r--Emby.Server.Implementations/Localization/Core/kk.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/ml.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/ms.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/my.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/nl.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/pl.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-BR.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ru.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/sv.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/th.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json23
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json55
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json7
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs45
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs42
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs22
-rw-r--r--Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs4
-rw-r--r--Emby.Server.Implementations/Sorting/StudioComparer.cs4
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs7
-rw-r--r--Jellyfin.Api/Controllers/ArtistsController.cs68
-rw-r--r--Jellyfin.Api/Controllers/AudioController.cs6
-rw-r--r--Jellyfin.Api/Controllers/ChannelsController.cs70
-rw-r--r--Jellyfin.Api/Controllers/ConfigurationController.cs20
-rw-r--r--Jellyfin.Api/Controllers/DisplayPreferencesController.cs12
-rw-r--r--Jellyfin.Api/Controllers/DynamicHlsController.cs53
-rw-r--r--Jellyfin.Api/Controllers/EnvironmentController.cs15
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs19
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs34
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs14
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs11
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs4
-rw-r--r--Jellyfin.Api/Controllers/QuickConnectController.cs11
-rw-r--r--Jellyfin.Api/Controllers/TrickplayController.cs2
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs3
-rw-r--r--Jellyfin.Api/Controllers/UniversalAudioController.cs8
-rw-r--r--Jellyfin.Api/Controllers/UserController.cs23
-rw-r--r--Jellyfin.Api/Controllers/VideosController.cs6
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs129
-rw-r--r--Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs47
-rw-r--r--Jellyfin.Api/Helpers/HlsHelpers.cs10
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs11
-rw-r--r--Jellyfin.Api/Jellyfin.Api.csproj2
-rw-r--r--Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs17
-rw-r--r--Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs8
-rw-r--r--Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs22
-rw-r--r--Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs15
-rw-r--r--Jellyfin.Data/Jellyfin.Data.csproj2
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs15
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs258
-rw-r--r--Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs6
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs27
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs5
-rw-r--r--Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj3
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs2
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs2
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs67
-rw-r--r--Jellyfin.Server/Filters/AdditionalModelFilter.cs110
-rw-r--r--Jellyfin.Server/Filters/CachingOpenApiProvider.cs20
-rw-r--r--Jellyfin.Server/Filters/FileRequestFilter.cs5
-rw-r--r--Jellyfin.Server/Filters/FileResponseFilter.cs11
-rw-r--r--Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs58
-rw-r--r--Jellyfin.Server/Filters/IgnoreEnumSchemaFilter.cs17
-rw-r--r--Jellyfin.Server/Filters/ParameterObsoleteFilter.cs12
-rw-r--r--Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs10
-rw-r--r--Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs15
-rw-r--r--Jellyfin.Server/Filters/SecuritySchemeReferenceFixupFilter.cs56
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj5
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs106
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs21
-rw-r--r--MediaBrowser.Common/MediaBrowser.Common.csproj7
-rw-r--r--MediaBrowser.Common/Net/NetworkConstants.cs1
-rw-r--r--MediaBrowser.Common/Net/NetworkUtils.cs35
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs32
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs23
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs73
-rw-r--r--MediaBrowser.Controller/Entities/Movies/BoxSet.cs8
-rw-r--r--MediaBrowser.Controller/Entities/TV/Season.cs7
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs8
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs15
-rw-r--r--MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs49
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj4
-rw-r--r--MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs2
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs97
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs15
-rw-r--r--MediaBrowser.Controller/MediaEncoding/JobLogger.cs6
-rw-r--r--MediaBrowser.Controller/Persistence/IItemRepository.cs8
-rw-r--r--MediaBrowser.Controller/Playlists/IPlaylistManager.cs3
-rw-r--r--MediaBrowser.Controller/Session/ISessionManager.cs7
-rw-r--r--MediaBrowser.Controller/Sorting/SortExtensions.cs4
-rw-r--r--MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj2
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs2
-rw-r--r--MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj3
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs45
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs52
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs2
-rw-r--r--MediaBrowser.Model/Configuration/EncodingOptions.cs8
-rw-r--r--MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs23
-rw-r--r--MediaBrowser.Model/Dlna/ConditionProcessor.cs2
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs3
-rw-r--r--MediaBrowser.Model/Dlna/StreamInfo.cs20
-rw-r--r--MediaBrowser.Model/Dlna/TranscodingProfile.cs4
-rw-r--r--MediaBrowser.Model/Entities/MediaStream.cs52
-rw-r--r--MediaBrowser.Model/Extensions/EnumerableExtensions.cs8
-rw-r--r--MediaBrowser.Model/Extensions/StringHelper.cs9
-rw-r--r--MediaBrowser.Model/MediaBrowser.Model.csproj5
-rw-r--r--MediaBrowser.Model/Net/IPData.cs5
-rw-r--r--MediaBrowser.Model/Providers/RemoteSearchResult.cs11
-rw-r--r--MediaBrowser.Model/Session/ClientCapabilities.cs11
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs120
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs100
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs35
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs94
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs329
-rw-r--r--MediaBrowser.Providers/Manager/ItemImageProvider.cs42
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs11
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs2
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj3
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs9
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs13
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs23
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs39
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs10
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs19
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs43
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs70
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs35
-rw-r--r--MediaBrowser.Providers/TV/SeriesMetadataService.cs33
-rw-r--r--MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj2
-rw-r--r--README.md15
-rw-r--r--fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj2
-rwxr-xr-xfuzz/Emby.Server.Implementations.Fuzz/fuzz.sh2
-rw-r--r--fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj2
-rwxr-xr-xfuzz/Jellyfin.Api.Fuzz/fuzz.sh2
-rw-r--r--global.json2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs1
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj2
-rw-r--r--src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj2
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs86
-rw-r--r--src/Jellyfin.Drawing/ImageProcessor.cs1
-rw-r--r--src/Jellyfin.Drawing/Jellyfin.Drawing.csproj2
-rw-r--r--src/Jellyfin.Extensions/AlphanumericComparator.cs112
-rw-r--r--src/Jellyfin.Extensions/Jellyfin.Extensions.csproj2
-rw-r--r--src/Jellyfin.Extensions/StringExtensions.cs2
-rw-r--r--src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj3
-rw-r--r--src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj5
-rw-r--r--src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj6
-rw-r--r--src/Jellyfin.Networking/Jellyfin.Networking.csproj2
-rw-r--r--src/Jellyfin.Networking/Manager/NetworkManager.cs44
-rw-r--r--tests/Directory.Build.props2
-rw-r--r--tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj1
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/InternalItemsQueryTests.cs26
-rw-r--r--tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs34
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj2
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs30
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_web_like_mkv_with_subtitle.json137
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs2
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs6
-rw-r--r--tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs43
-rw-r--r--tests/Jellyfin.Model.Tests/Extensions/EnumerableExtensionsTests.cs116
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidPixel.json3
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer.json2
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome-NoHLS.json11
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json7
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json3
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-JellyfinMediaPlayer.json3
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-LowBandwidth.json3
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlus.json6
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlusNext.json6
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json7
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json13
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json13
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-TranscodeMedia.json4
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json4
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse.json3
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse2.json3
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkParseTests.cs8
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Cryptography/CryptographyProviderTests.cs102
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs72
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs87
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs19
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs19
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj1
-rw-r--r--tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj1
-rw-r--r--tests/Jellyfin.Server.Tests/ParseNetworkTests.cs7
256 files changed, 3492 insertions, 1840 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 029a48f6a..9cd9c08e7 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "9.0.11",
+ "version": "10.0.5",
"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/.editorconfig b/.editorconfig
index 313b02563..fa679f120 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -379,6 +379,9 @@ dotnet_diagnostic.CA1720.severity = suggestion
# disable warning CA1724: Type names should not match namespaces
dotnet_diagnostic.CA1724.severity = suggestion
+# disable warning CA1873: Avoid potentially expensive logging
+dotnet_diagnostic.CA1873.severity = suggestion
+
# disable warning CA1805: Do not initialize unnecessarily
dotnet_diagnostic.CA1805.severity = suggestion
@@ -400,6 +403,10 @@ dotnet_diagnostic.CA1861.severity = suggestion
# disable warning CA2000: Dispose objects before losing scope
dotnet_diagnostic.CA2000.severity = suggestion
+# TODO: Reevaluate when false positives are fixed: https://github.com/dotnet/roslyn-analyzers/issues/7699
+# disable warning CA2025: Do not pass 'IDisposable' instances into unawaited tasks
+dotnet_diagnostic.CA2025.severity = suggestion
+
# disable warning CA2253: Named placeholders should not be numeric values
dotnet_diagnostic.CA2253.severity = suggestion
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml
index a505d4168..9bcff76bd 100644
--- a/.github/ISSUE_TEMPLATE/issue report.yml
+++ b/.github/ISSUE_TEMPLATE/issue report.yml
@@ -87,6 +87,7 @@ body:
label: Jellyfin Server version
description: What version of Jellyfin are you using?
options:
+ - 10.11.6
- 10.11.5
- 10.11.4
- 10.11.3
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 5ca3edd7b..9eadf7632 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -20,18 +20,21 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
- name: Setup .NET
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
- dotnet-version: '9.0.x'
+ dotnet-version: '10.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
+ uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
languages: ${{ matrix.language }}
queries: +security-extended
+
- name: Autobuild
- uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
+ uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
+
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
+ uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml
index a3c49969c..f9e2fbc3a 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: {}
@@ -11,22 +11,22 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
- dotnet-version: '9.0.x'
+ dotnet-version: '10.0.x'
- name: Build
run: |
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: abi-head
retention-days: 14
@@ -40,16 +40,16 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Setup .NET
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
- dotnet-version: '9.0.x'
+ dotnet-version: '10.0.x'
- name: Checkout common ancestor
env:
@@ -65,7 +65,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: abi-base
retention-days: 14
@@ -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
@@ -85,13 +85,13 @@ jobs:
steps:
- name: Download abi-head
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: abi-head
path: abi-head
- name: Download abi-base
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: abi-base
path: abi-base
@@ -106,7 +106,7 @@ jobs:
{
echo 'body<<EOF'
for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll Jellyfin.MediaEncoding.Keyframes.dll Jellyfin.Database.Implementations.dll; do
- COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 )"
+ COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 || true )"
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
printf "\n${file}\n${COMPAT_OUTPUT}\n"
fi
diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml
index 46af68e58..f4fd0829b 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: {}
@@ -16,23 +16,25 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
+
- name: Setup .NET
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
- dotnet-version: '9.0.x'
+ dotnet-version: '10.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
+
- name: Upload openapi.json
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: openapi-head
retention-days: 14
if-no-files-found: error
- path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.0/openapi.json
+ path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
openapi-base:
name: OpenAPI - BASE
@@ -41,11 +43,12 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
+
- name: Checkout common ancestor
env:
HEAD_REF: ${{ github.head_ref }}
@@ -54,101 +57,59 @@ jobs:
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
git checkout --progress --force $ANCESTOR_REF
+
- name: Setup .NET
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
+ uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
- dotnet-version: '9.0.x'
+ dotnet-version: '10.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
+
- name: Upload openapi.json
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: openapi-base
retention-days: 14
if-no-files-found: error
- path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net9.0/openapi.json
+ path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
openapi-diff:
permissions:
- pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
+ 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
- openapi-base
steps:
- name: Download openapi-head
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openapi-head
path: openapi-head
+
- name: Download openapi-base
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openapi-base
path: openapi-base
- - name: Workaround openapi-diff issue
- run: |
- sed -i 's/"allOf"/"oneOf"/g' openapi-head/openapi.json
- sed -i 's/"allOf"/"oneOf"/g' openapi-base/openapi.json
- - name: Calculate OpenAPI difference
- uses: docker://openapitools/openapi-diff
- continue-on-error: true
- with:
- args: --fail-on-changed --markdown openapi-changes.md openapi-base/openapi.json openapi-head/openapi.json
- - id: read-diff
- name: Read openapi-diff output
- run: |
- # Read and fix markdown
- body=$(cat openapi-changes.md)
- # Write to workflow summary
- echo "$body" >> $GITHUB_STEP_SUMMARY
- # Set ApiChanged var
- if [ "$body" != '' ]; then
- echo "ApiChanged=1" >> "$GITHUB_OUTPUT"
- else
- echo "ApiChanged=0" >> "$GITHUB_OUTPUT"
- fi
- # Add header/footer for diff comment
- echo '<!--openapi-diff-workflow-comment-->' > openapi-changes-reply.md
- echo "<details>" >> openapi-changes-reply.md
- echo "<summary>Changes in OpenAPI specification found. Expand to see details.</summary>" >> openapi-changes-reply.md
- echo "" >> openapi-changes-reply.md
- echo "$body" >> openapi-changes-reply.md
- echo "" >> openapi-changes-reply.md
- echo "</details>" >> openapi-changes-reply.md
- - name: Find difference comment
- uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
- id: find-comment
- with:
- issue-number: ${{ github.event.pull_request.number }}
- direction: last
- body-includes: openapi-diff-workflow-comment
- - name: Reply or edit difference comment (changed)
- uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
- if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
- with:
- issue-number: ${{ github.event.pull_request.number }}
- comment-id: ${{ steps.find-comment.outputs.comment-id }}
- edit-mode: replace
- body-path: openapi-changes-reply.md
- - name: Edit difference comment (unchanged)
- uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
- if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }}
- with:
- issue-number: ${{ github.event.pull_request.number }}
- comment-id: ${{ steps.find-comment.outputs.comment-id }}
- edit-mode: replace
- body: |
- <!--openapi-diff-workflow-comment-->
- No changes to OpenAPI specification found. See history of this comment for previous changes.
+ - name: Detect OpenAPI changes
+ id: openapi-diff
+ uses: jellyfin/openapi-diff-action@9274f6bda9d01ab091942a4a8334baa53692e8a4 # v1.0.0
+ with:
+ old-spec: openapi-base/openapi.json
+ new-spec: openapi-head/openapi.json
+ markdown: openapi-changelog.md
+ add-pr-comment: true
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
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
@@ -158,7 +119,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
- name: Download openapi-head
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openapi-head
path: openapi-head
@@ -172,13 +133,12 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (unstable) into place
- uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
+ uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
key: "${{ secrets.REPO_KEY }}"
debug: false
- script_stop: false
script: |
if ! test -d /run/workflows; then
sudo mkdir -p /run/workflows
@@ -220,7 +180,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Download openapi-head
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openapi-head
path: openapi-head
@@ -234,13 +194,12 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (stable) into place
- uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
+ uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
key: "${{ secrets.REPO_KEY }}"
debug: false
- script_stop: false
script: |
if ! test -d /run/workflows; then
sudo mkdir -p /run/workflows
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index f70243221..7586e826b 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -9,7 +9,7 @@ on:
pull_request:
env:
- SDK_VERSION: "9.0.x"
+ SDK_VERSION: "10.0.x"
jobs:
run-tests:
@@ -20,9 +20,9 @@ jobs:
runs-on: "${{ matrix.os }}"
steps:
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
+ - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
@@ -35,7 +35,7 @@ jobs:
--verbosity minimal
- name: Merge code coverage results
- uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
+ uses: danielpalme/ReportGenerator-GitHub-Action@2a82782178b2816d9d6960a7345fdd164791b323 # v5.5.3
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index 0d3e09d1a..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
@@ -24,7 +24,7 @@ jobs:
reactions: '+1'
- name: Checkout the latest code
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -40,16 +40,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: jellyfin/jellyfin-triage-script
+
- name: install python
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.14'
cache: 'pip'
+
- name: install python packages
run: pip install -r rename/requirements.txt
+
- name: run rename script
run: python3 rename.py
working-directory: ./rename
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/issue-template-check.yml b/.github/workflows/issue-template-check.yml
index 8be48b5c3..dcd1fb7cf 100644
--- a/.github/workflows/issue-template-check.yml
+++ b/.github/workflows/issue-template-check.yml
@@ -10,16 +10,19 @@ jobs:
issues: write
steps:
- name: pull in script
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: jellyfin/jellyfin-triage-script
+
- name: install python
- uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.14'
cache: 'pip'
+
- name: install python packages
run: pip install -r main-repo-triage/requirements.txt
+
- name: check and comment issue
working-directory: ./main-repo-triage
run: python3 single_issue_gha.py
diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml
index d62f655b3..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: {}
@@ -21,6 +21,7 @@ jobs:
with:
project: Current Release
action: delete
+ column: In progress
repo-token: ${{ secrets.JF_BOT_TOKEN }}
- name: Add to 'Release Next' project
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/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml
index d39d2cb9c..4c6b6b8e7 100644
--- a/.github/workflows/release-bump-version.yaml
+++ b/.github/workflows/release-bump-version.yaml
@@ -33,7 +33,7 @@ jobs:
yq-version: v4.9.8
- name: Checkout Repository
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ env.TAG_BRANCH }}
diff --git a/.vscode/launch.json b/.vscode/launch.json
index d97d8de84..681f068b9 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -6,7 +6,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
- "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll",
+ "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
"args": [],
"cwd": "${workspaceFolder}/Jellyfin.Server",
"console": "internalConsole",
@@ -22,7 +22,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
- "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll",
+ "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
"args": ["--nowebclient"],
"cwd": "${workspaceFolder}/Jellyfin.Server",
"console": "internalConsole",
@@ -34,7 +34,7 @@
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
- "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net9.0/jellyfin.dll",
+ "program": "${workspaceFolder}/Jellyfin.Server/bin/Debug/net10.0/jellyfin.dll",
"args": ["--nowebclient", "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"],
"cwd": "${workspaceFolder}/Jellyfin.Server",
"console": "internalConsole",
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 4406413da..cb7d3fbbc 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -117,6 +117,7 @@
- [sachk](https://github.com/sachk)
- [sammyrc34](https://github.com/sammyrc34)
- [samuel9554](https://github.com/samuel9554)
+ - [SapientGuardian](https://github.com/SapientGuardian)
- [scheidleon](https://github.com/scheidleon)
- [sebPomme](https://github.com/sebPomme)
- [SegiH](https://github.com/SegiH)
@@ -205,7 +206,11 @@
- [theshoeshiner](https://github.com/theshoeshiner)
- [TokerX](https://github.com/TokerX)
- [GeneMarks](https://github.com/GeneMarks)
+ - [Kirill Nikiforov](https://github.com/allmazz)
- [bjorntp](https://github.com/bjorntp)
+ - [martenumberto](https://github.com/martenumberto)
+ - [ZeusCraft10](https://github.com/ZeusCraft10)
+ - [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
# Emby Contributors
@@ -282,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 564223483..294cb45b1 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="7.1.8" />
+ <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,8 +13,8 @@
<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="Diacritics" Version="4.0.17" />
+ <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" />
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
@@ -25,34 +25,29 @@
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
<PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" />
- <PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
- <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
- <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
+ <PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
+ <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
- <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
- <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
- <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
- <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
- <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
- <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
+ <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
<PackageVersion Include="Moq" Version="4.18.4" />
@@ -62,11 +57,11 @@
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
- <PackageVersion Include="Polly" Version="8.6.5" />
- <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
+ <PackageVersion Include="Polly" Version="8.6.6" />
+ <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
- <PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
+ <PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
@@ -79,21 +74,17 @@
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="[3.116.1]" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
- <PackageVersion Include="Svg.Skia" Version="3.2.1" />
- <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
- <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
- <PackageVersion Include="System.Globalization" Version="4.3.0" />
- <PackageVersion Include="System.Linq.Async" Version="6.0.3" />
- <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
- <PackageVersion Include="System.Text.Json" Version="9.0.11" />
- <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
+ <PackageVersion Include="Svg.Skia" Version="3.4.1" />
+ <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.1.5" />
+ <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.1.5" />
+ <PackageVersion Include="System.Text.Json" Version="10.0.5" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
- <PackageVersion Include="z440.atl.core" Version="7.9.0" />
- <PackageVersion Include="TMDbLib" Version="2.3.0" />
+ <PackageVersion Include="z440.atl.core" Version="7.11.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" />
- <PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
+ <PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="xunit" Version="2.9.3" />
</ItemGroup>
</Project>
diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs
index f61ca7e12..9103174d2 100644
--- a/Emby.Naming/Common/NamingOptions.cs
+++ b/Emby.Naming/Common/NamingOptions.cs
@@ -225,6 +225,7 @@ namespace Emby.Naming.Common
".afc",
".amf",
".aif",
+ ".aifc",
".aiff",
".alac",
".amr",
diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj
index b84c96116..97b52e42a 100644
--- a/Emby.Naming/Emby.Naming.csproj
+++ b/Emby.Naming/Emby.Naming.csproj
@@ -6,7 +6,7 @@
</PropertyGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
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.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs
index a3134f3f6..4247fea0e 100644
--- a/Emby.Naming/Video/VideoListResolver.cs
+++ b/Emby.Naming/Video/VideoListResolver.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
@@ -136,19 +137,27 @@ namespace Emby.Naming.Video
if (videos.Count > 1)
{
- var groups = videos.GroupBy(x => ResolutionRegex().IsMatch(x.Files[0].FileNameWithoutExtension)).ToList();
+ var groups = videos
+ .Select(x => (filename: x.Files[0].FileNameWithoutExtension.ToString(), value: x))
+ .Select(x => (x.filename, resolutionMatch: ResolutionRegex().Match(x.filename), x.value))
+ .GroupBy(x => x.resolutionMatch.Success)
+ .ToList();
+
videos.Clear();
+
+ StringComparer comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
foreach (var group in groups)
{
if (group.Key)
{
videos.InsertRange(0, group
- .OrderByDescending(x => ResolutionRegex().Match(x.Files[0].FileNameWithoutExtension.ToString()).Value, new AlphanumericComparator())
- .ThenBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
+ .OrderByDescending(x => x.resolutionMatch.Value, comparer)
+ .ThenBy(x => x.filename, comparer)
+ .Select(x => x.value));
}
else
{
- videos.AddRange(group.OrderBy(x => x.Files[0].FileNameWithoutExtension.ToString(), new AlphanumericComparator()));
+ videos.AddRange(group.OrderBy(x => x.filename, comparer).Select(x => x.value));
}
}
}
diff --git a/Emby.Photos/Emby.Photos.csproj b/Emby.Photos/Emby.Photos.csproj
index 645a74aea..3faeae380 100644
--- a/Emby.Photos/Emby.Photos.csproj
+++ b/Emby.Photos/Emby.Photos.csproj
@@ -19,7 +19,7 @@
</ItemGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs
index 5380c45d8..0381c4d35 100644
--- a/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs
+++ b/Emby.Server.Implementations/Cryptography/CryptographyProvider.cs
@@ -39,22 +39,24 @@ namespace Emby.Server.Implementations.Cryptography
{
if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
{
+ var iterations = GetIterationsParameter(hash);
return hash.Hash.SequenceEqual(
Rfc2898DeriveBytes.Pbkdf2(
password,
hash.Salt,
- int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
+ iterations,
HashAlgorithmName.SHA1,
32));
}
if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
{
+ var iterations = GetIterationsParameter(hash);
return hash.Hash.SequenceEqual(
Rfc2898DeriveBytes.Pbkdf2(
password,
hash.Salt,
- int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
+ iterations,
HashAlgorithmName.SHA512,
DefaultOutputLength));
}
@@ -62,6 +64,27 @@ namespace Emby.Server.Implementations.Cryptography
throw new NotSupportedException($"Can't verify hash with id: {hash.Id}");
}
+ /// <summary>
+ /// Extracts and validates the iterations parameter from a password hash.
+ /// </summary>
+ /// <param name="hash">The password hash containing parameters.</param>
+ /// <returns>The number of iterations.</returns>
+ /// <exception cref="FormatException">Thrown when iterations parameter is missing or invalid.</exception>
+ private static int GetIterationsParameter(PasswordHash hash)
+ {
+ if (!hash.Parameters.TryGetValue("iterations", out var iterationsStr))
+ {
+ throw new FormatException($"Password hash with id '{hash.Id}' is missing required 'iterations' parameter.");
+ }
+
+ if (!int.TryParse(iterationsStr, CultureInfo.InvariantCulture, out var iterations))
+ {
+ throw new FormatException($"Password hash with id '{hash.Id}' has invalid 'iterations' parameter: '{iterationsStr}'.");
+ }
+
+ return iterations;
+ }
+
/// <inheritdoc />
public byte[] GenerateSalt()
=> GenerateSalt(DefaultSaltLength);
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index c5dc3b054..b392340f7 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -1051,16 +1051,16 @@ namespace Emby.Server.Implementations.Dto
// Include artists that are not in the database yet, e.g., just added via metadata editor
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
- dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
- .Where(e => e.Value.Length > 0)
- .Select(i =>
- {
- return new NameGuidPair
- {
- Name = i.Key,
- Id = i.Value.First().Id
- };
- }).Where(i => i is not null).ToArray();
+ var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
+
+ dto.ArtistItems = hasArtist.Artists
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .Distinct()
+ .Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
+ ? new NameGuidPair { Name = name, Id = artists[0].Id }
+ : null)
+ .Where(item => item is not null)
+ .ToArray();
}
if (item is IHasAlbumArtist hasAlbumArtist)
@@ -1085,31 +1085,16 @@ namespace Emby.Server.Implementations.Dto
// })
// .ToList();
- dto.AlbumArtists = hasAlbumArtist.AlbumArtists
- // .Except(foundArtists, new DistinctNameComparer())
- .Select(i =>
- {
- // This should not be necessary but we're seeing some cases of it
- if (string.IsNullOrEmpty(i))
- {
- return null;
- }
-
- var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
- {
- EnableImages = false
- });
- if (artist is not null)
- {
- return new NameGuidPair
- {
- Name = artist.Name,
- Id = artist.Id
- };
- }
+ var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
- return null;
- }).Where(i => i is not null).ToArray();
+ dto.AlbumArtists = hasAlbumArtist.AlbumArtists
+ .Where(name => !string.IsNullOrWhiteSpace(name))
+ .Distinct()
+ .Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
+ ? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
+ : null)
+ .Where(item => item is not null)
+ .ToArray();
}
// Add video info
diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
index 15843730e..f312fb4db 100644
--- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj
+++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
@@ -27,7 +27,6 @@
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
- <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="prometheus-net.DotNetRuntime" />
@@ -39,7 +38,7 @@
</ItemGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs
index d87ad729e..7cff2a25b 100644
--- a/Emby.Server.Implementations/IO/LibraryMonitor.cs
+++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs
@@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO
return;
}
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+ if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
+ {
+ return;
+ }
+
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
foreach (var i in _tempIgnoredPaths.Keys)
{
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/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
index 273d356a3..095934f89 100644
--- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
@@ -40,7 +40,7 @@ namespace Emby.Server.Implementations.Images
includeItemTypes = new[] { BaseItemKind.Series };
break;
case CollectionType.music:
- includeItemTypes = new[] { BaseItemKind.MusicAlbum };
+ includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
break;
case CollectionType.musicvideos:
includeItemTypes = new[] { BaseItemKind.MusicVideo };
@@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
}
+
+ protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
+ {
+ var age = DateTime.UtcNow - image.DateModified;
+ return age.TotalDays > 7;
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
index 473ff8e1d..ef5d24c70 100644
--- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
+++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Text.RegularExpressions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Resolvers;
@@ -70,12 +71,55 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
{
// If file has content, base ignoring off the content .gitignore-style rules
var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ return CheckIgnoreRules(path, rules, isDirectory);
+ }
+
+ /// <summary>
+ /// Checks whether a path should be ignored based on an array of ignore rules.
+ /// </summary>
+ /// <param name="path">The path to check.</param>
+ /// <param name="rules">The array of ignore rules.</param>
+ /// <param name="isDirectory">Whether the path is a directory.</param>
+ /// <returns>True if the path should be ignored.</returns>
+ internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory)
+ => CheckIgnoreRules(path, rules, isDirectory, IsWindows);
+
+ /// <summary>
+ /// Checks whether a path should be ignored based on an array of ignore rules.
+ /// </summary>
+ /// <param name="path">The path to check.</param>
+ /// <param name="rules">The array of ignore rules.</param>
+ /// <param name="isDirectory">Whether the path is a directory.</param>
+ /// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param>
+ /// <returns>True if the path should be ignored.</returns>
+ internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath)
+ {
var ignore = new Ignore.Ignore();
- ignore.Add(rules);
+
+ // Add each rule individually to catch and skip invalid patterns
+ var validRulesAdded = 0;
+ foreach (var rule in rules)
+ {
+ try
+ {
+ ignore.Add(rule);
+ validRulesAdded++;
+ }
+ catch (RegexParseException)
+ {
+ // Ignore invalid patterns
+ }
+ }
+
+ // If no valid rules were added, fall back to ignoring everything (like an empty .ignore file)
+ if (validRulesAdded == 0)
+ {
+ return true;
+ }
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
// See https://github.com/jellyfin/jellyfin/issues/15484
- var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
+ var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
// Add trailing slash for directories to match "folder/"
if (isDirectory)
diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs
index fe3a1ce61..59ccb9e2c 100644
--- a/Emby.Server.Implementations/Library/IgnorePatterns.cs
+++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs
@@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library
"**/lost+found",
"**/subs/**",
"**/subs",
+ "**/.snapshots/**",
+ "**/.snapshots",
+ "**/.snapshot/**",
+ "**/.snapshot",
// Trickplay files
"**/*.trickplay",
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 30c3e89b4..eee87c4d8 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1058,6 +1058,7 @@ namespace Emby.Server.Implementations.Library
{
IncludeItemTypes = [BaseItemKind.MusicArtist],
Name = name,
+ UseRawName = true,
DtoOptions = options
}).Cast<MusicArtist>()
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
@@ -2201,6 +2202,12 @@ namespace Emby.Server.Implementations.Library
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
+ /// <inheritdoc />
+ public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
+ {
+ await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false);
+ }
+
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
{
if (item.IsFileProtocol)
@@ -2282,7 +2289,7 @@ namespace Emby.Server.Implementations.Library
if (item is null)
{
- return new List<Folder>();
+ return [];
}
return GetCollectionFoldersInternal(item, allUserRootChildren);
@@ -3194,19 +3201,7 @@ namespace Emby.Server.Implementations.Library
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
- var shortcutFilename = Path.GetFileNameWithoutExtension(path);
-
- var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
-
- while (File.Exists(lnk))
- {
- shortcutFilename += "1";
- lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
- }
-
- _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
-
- RemoveContentTypeOverrides(path);
+ CreateShortcut(virtualFolderPath, pathInfo);
if (saveLibraryOptions)
{
@@ -3371,5 +3366,24 @@ namespace Emby.Server.Implementations.Library
return item is UserRootFolder || item.IsVisibleStandalone(user);
}
+
+ public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo)
+ {
+ var path = pathInfo.Path;
+ var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
+
+ var shortcutFilename = Path.GetFileNameWithoutExtension(path);
+
+ var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
+
+ while (File.Exists(lnk))
+ {
+ shortcutFilename += "1";
+ lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
+ }
+
+ _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
+ RemoveContentTypeOverrides(path);
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs
index 21e7079d8..fc63251ad 100644
--- a/Emby.Server.Implementations/Library/PathExtensions.cs
+++ b/Emby.Server.Implementations/Library/PathExtensions.cs
@@ -37,15 +37,25 @@ namespace Emby.Server.Implementations.Library
while (attributeIndex > -1 && attributeIndex < maxIndex)
{
var attributeEnd = attributeIndex + attribute.Length;
- if (attributeIndex > 0
- && str[attributeIndex - 1] == '['
- && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
+ if (attributeIndex > 0)
{
- var closingIndex = str[attributeEnd..].IndexOf(']');
- // Must be at least 1 character before the closing bracket.
- if (closingIndex > 1)
+ var attributeOpener = str[attributeIndex - 1];
+ var attributeCloser = attributeOpener switch
{
- return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
+ '[' => ']',
+ '(' => ')',
+ '{' => '}',
+ _ => '\0'
+ };
+ if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
+ {
+ var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
+
+ // Must be at least 1 character before the closing bracket.
+ if (closingIndex > 1)
+ {
+ return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
+ }
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json
index 1dce58923..59fb33941 100644
--- a/Emby.Server.Implementations/Localization/Core/af.json
+++ b/Emby.Server.Implementations/Localization/Core/af.json
@@ -135,5 +135,7 @@
"TaskExtractMediaSegments": "Media Segment Skandeer",
"TaskExtractMediaSegmentsDescription": "Onttrek of verkry mediasegmente van MediaSegment-geaktiveerde inproppe.",
"TaskMoveTrickplayImages": "Migreer Trickplay Beeldligging",
- "TaskMoveTrickplayImagesDescription": "Skuif ontstaande trickplay lêers volgens die biblioteekinstellings."
+ "TaskMoveTrickplayImagesDescription": "Skuif ontstaande trickplay lêers volgens die biblioteekinstellings.",
+ "CleanupUserDataTask": "Gebruikers data skoon maak taak",
+ "CleanupUserDataTaskDescription": "Maak alle gebruikers data (kykstatus, gunstelingstatus, ens.) skoon van media wat nie meer vir ten minste 90 dae teenwoordig is nie."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json
index 24ed116f3..7ce8baef5 100644
--- a/Emby.Server.Implementations/Localization/Core/ar.json
+++ b/Emby.Server.Implementations/Localization/Core/ar.json
@@ -2,13 +2,13 @@
"Albums": "ألبومات",
"AppDeviceValues": "تطبيق: {0}, جهاز: {1}",
"Application": "تطبيق",
- "Artists": "الفنانون",
+ "Artists": "فنانون",
"AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}",
"Books": "الكتب",
"CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}",
"Channels": "القنوات",
"ChapterNameValue": "الفصل {0}",
- "Collections": "المجموعات",
+ "Collections": "مجموعات",
"DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
"DeviceOnlineWithName": "{0} متصل",
"FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}",
@@ -16,7 +16,7 @@
"Folders": "المجلدات",
"Genres": "التصنيفات",
"HeaderAlbumArtists": "فناني الألبوم",
- "HeaderContinueWatching": "أكمل المشاهدة",
+ "HeaderContinueWatching": "متابعة المشاهدة",
"HeaderFavoriteAlbums": "الألبومات المفضلة",
"HeaderFavoriteArtists": "الفنانون المفضلون",
"HeaderFavoriteEpisodes": "الحلقات المفضلة",
@@ -73,7 +73,6 @@
"Shows": "العروض",
"Songs": "الأغاني",
"StartupEmbyServerIsLoading": "يتم تحميل خادم Jellyfin . الرجاء المحاولة بعد قليل.",
- "SubtitleDownloadFailureForItem": "عملية إنزال الترجمة فشلت لـ{0}",
"SubtitleDownloadFailureFromForItem": "فشل تحميل الترجمات من {0} ل {1}",
"Sync": "مزامنة",
"System": "النظام",
diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json
index 29847048c..8ef3d9afd 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,9 +14,9 @@
"Channels": "Каналы",
"ChapterNameValue": "Раздзел {0}",
"Collections": "Калекцыі",
- "Default": "Па змаўчанні",
+ "Default": "Прадвызначана",
"FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}",
- "Folders": "Тэчкі",
+ "Folders": "Папкі",
"Favorites": "Абранае",
"External": "Знешні",
"Genres": "Жанры",
@@ -50,7 +50,7 @@
"User": "Карыстальнік",
"UserDeletedWithName": "Карыстальнік {0} быў выдалены",
"UserDownloadingItemWithValues": "{0} спампоўваецца {1}",
- "TaskOptimizeDatabase": "Аптымізаваць базу дадзеных",
+ "TaskOptimizeDatabase": "Аптымізацыя базы даных",
"Artists": "Выканаўцы",
"UserOfflineFromDevice": "{0} адлучыўся ад {1}",
"UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}",
@@ -59,8 +59,8 @@
"TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.",
"TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія сканфігураваныя на аўтаматычнае абнаўленне.",
"TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.",
- "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метададзеных.",
- "TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых зменаў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць выдайнасць.",
+ "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метаданых.",
+ "TaskOptimizeDatabaseDescription": "Сціскае базу даных і вызваляе вольную прастору. Выкананне гэтай задачы пасля сканіравання бібліятэкі або іншых змяненняў, якія мадыфікуюць базу даных, можа палепшыць прадукцыйнасць.",
"TaskKeyframeExtractor": "Экстрактар ключавых кадраў",
"TasksApplicationCategory": "Праграма",
"AppDeviceValues": "Праграма: {0}, Прылада: {1}",
@@ -81,8 +81,8 @@
"NotificationOptionInstallationFailed": "Збой усталёўкі",
"NewVersionIsAvailable": "Новая версія сервера Jellyfin даступная для cпампоўкі.",
"NotificationOptionCameraImageUploaded": "Выява камеры запампавана",
- "NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыё спынена",
- "NotificationOptionAudioPlayback": "Прайграванне аўдыё пачалося",
+ "NotificationOptionAudioPlaybackStopped": "Прайграванне аўдыя спынена",
+ "NotificationOptionAudioPlayback": "Прайграванне аўдыя пачалося",
"NotificationOptionNewLibraryContent": "Дададзены новы кантэнт",
"NotificationOptionPluginError": "Збой плагіна",
"NotificationOptionPluginUninstalled": "Плагін выдалены",
@@ -95,7 +95,7 @@
"ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску",
"Shows": "Шоу",
"StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.",
- "SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}",
+ "SubtitleDownloadFailureFromForItem": "Субцітры для {1} не ўдалося спампаваць з {0}",
"TvShows": "Тэлепраграма",
"Undefined": "Нявызначана",
"UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны",
@@ -104,7 +104,7 @@
"UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}",
"UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}",
"ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку",
- "ValueSpecialEpisodeName": "Спецэпізод - {0}",
+ "ValueSpecialEpisodeName": "Спецвыпуск - {0}",
"VersionNumber": "Версія {0}",
"TasksMaintenanceCategory": "Абслугоўванне",
"TasksLibraryCategory": "Бібліятэка",
@@ -114,7 +114,7 @@
"TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.",
"TaskRefreshChapterImages": "Вынуць выявы раздзелаў",
"TaskRefreshLibrary": "Сканаваць бібліятэку",
- "TaskRefreshLibraryDescription": "Скануе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.",
+ "TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метаданыя.",
"TaskCleanLogs": "Ачысціць журнал",
"TaskRefreshPeople": "Абнавіць выканаўцаў",
"TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.",
@@ -123,10 +123,10 @@
"TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.",
"TaskRefreshChannels": "Абнавіць каналы",
"TaskDownloadMissingSubtitles": "Спампаваць адсутныя субцітры",
- "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа працягнуцца шмат часу.",
+ "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа выконвацца доўга.",
"TaskRefreshTrickplayImages": "Стварыць выявы Trickplay",
"TaskRefreshTrickplayImagesDescription": "Стварае перадпрагляды відэаролікаў для Trickplay у падключаных бібліятэках.",
- "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і плэй-лісты",
+ "TaskCleanCollectionsAndPlaylists": "Ачысціць калекцыі і плэй-лісты",
"TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і плэй-лістоў, якія больш не існуюць.",
"TaskAudioNormalizationDescription": "Скануе файлы на прадмет нармалізацыі гуку.",
"TaskAudioNormalization": "Нармалізацыя гуку",
@@ -136,6 +136,6 @@
"TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песняў",
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
- "CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка",
- "CleanupUserDataTaskDescription": "Ачысьціць усе дадзеныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
+ "CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка",
+ "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
}
diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json
index fd3666ef1..054c7357e 100644
--- a/Emby.Server.Implementations/Localization/Core/bg-BG.json
+++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json
@@ -15,7 +15,7 @@
"Favorites": "Любими",
"Folders": "Папки",
"Genres": "Жанрове",
- "HeaderAlbumArtists": "Изпълнители на албуми",
+ "HeaderAlbumArtists": "Изпълнители на албума",
"HeaderContinueWatching": "Продължаване на гледането",
"HeaderFavoriteAlbums": "Любими албуми",
"HeaderFavoriteArtists": "Любими изпълнители",
@@ -73,7 +73,6 @@
"Shows": "Сериали",
"Songs": "Песни",
"StartupEmbyServerIsLoading": "Сървърът зарежда. Моля, опитайте отново след малко.",
- "SubtitleDownloadFailureForItem": "Неуспешно изтегляне на субтитри за {0}",
"SubtitleDownloadFailureFromForItem": "Субтитрите за {1} от {0} не можаха да бъдат изтеглени",
"Sync": "Синхронизиране",
"System": "Система",
diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json
index 596df6348..1e7279be8 100644
--- a/Emby.Server.Implementations/Localization/Core/ca.json
+++ b/Emby.Server.Implementations/Localization/Core/ca.json
@@ -73,7 +73,6 @@
"Shows": "Sèries",
"Songs": "Cançons",
"StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho de nou en una estona.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}",
"Sync": "Sincronitza",
"System": "Sistema",
@@ -105,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/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json
index e14edcffa..4d2477044 100644
--- a/Emby.Server.Implementations/Localization/Core/cs.json
+++ b/Emby.Server.Implementations/Localization/Core/cs.json
@@ -73,7 +73,6 @@
"Shows": "Seriály",
"Songs": "Skladby",
"StartupEmbyServerIsLoading": "Jellyfin Server je spouštěn. Zkuste to prosím v brzké době znovu.",
- "SubtitleDownloadFailureForItem": "Stahování titulků selhalo pro {0}",
"SubtitleDownloadFailureFromForItem": "Stažení titulků pro {1} z {0} selhalo",
"Sync": "Synchronizace",
"System": "Systém",
diff --git a/Emby.Server.Implementations/Localization/Core/cy.json b/Emby.Server.Implementations/Localization/Core/cy.json
index 794a8e4ce..d9ebd13f0 100644
--- a/Emby.Server.Implementations/Localization/Core/cy.json
+++ b/Emby.Server.Implementations/Localization/Core/cy.json
@@ -8,7 +8,7 @@
"CameraImageUploadedFrom": "Mae delwedd camera newydd wedi'i lanlwytho o {0}",
"Books": "Llyfrau",
"AuthenticationSucceededWithUserName": "{0} wedi’i ddilysu’n llwyddiannus",
- "Artists": "Artistiaid",
+ "Artists": "Crewyr",
"AppDeviceValues": "Ap: {0}, Dyfais: {1}",
"Albums": "Albwmau",
"Genres": "Genres",
@@ -67,7 +67,7 @@
"NotificationOptionAudioPlayback": "Dechreuwyd chwarae sain",
"MessageServerConfigurationUpdated": "Mae gosodiadau gweinydd wedi'i ddiweddaru",
"MessageNamedServerConfigurationUpdatedWithValue": "Mae adran gosodiadau gweinydd {0} wedi'i diweddaru",
- "FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu gan {0}",
+ "FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu o {0}",
"ValueHasBeenAddedToLibrary": "{0} wedi'i hychwanegu at eich llyfrgell gyfryngau",
"UserStoppedPlayingItemWithValues": "{0} wedi gorffen chwarae {1} ar {2}",
"UserStartedPlayingItemWithValues": "{0} yn chwarae {1} ar {2}",
@@ -123,5 +123,14 @@
"TaskRefreshChapterImages": "Echdynnu Lluniau Pennod",
"TaskCleanCacheDescription": "Dileu ffeiliau cache nad oes eu hangen ar y system mwyach.",
"TaskCleanCache": "Gwaghau Ffolder Cache",
- "HearingImpaired": "Nam ar y clyw"
+ "HearingImpaired": "Nam ar y clyw",
+ "TaskAudioNormalization": "Gwastatau Sain",
+ "TaskAudioNormalizationDescription": "Yn sganio ffeiliau am ddata gwastatau sain.",
+ "TaskRefreshTrickplayImages": "Creuwch lluniau Trickplay",
+ "TaskRefreshTrickplayImagesDescription": "Creu rhagolygon Trickplay ar gyfer fideos mewn llyfrgelloedd gweithredol.",
+ "TaskDownloadMissingLyrics": "Lawrlwytho geiriau coll",
+ "TaskDownloadMissingLyricsDescription": "Lawrlwytho geiriau caneuon",
+ "TaskCleanCollectionsAndPlaylists": "Glanhau casgliadau a rhestrau chwarae",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Dileu eitemau o gasgliadau a rhestrau chwarae sydd ddim yn bodoli bellach.",
+ "TaskExtractMediaSegments": "Sganio Darnau Cyfryngau"
}
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index bbee38ba5..8b0d8745d 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -73,7 +73,6 @@
"Shows": "Serier",
"Songs": "Sange",
"StartupEmbyServerIsLoading": "Jellyfin er i gang med at starte. Prøv igen om et øjeblik.",
- "SubtitleDownloadFailureForItem": "Fejlet i download af undertekster for {0}",
"SubtitleDownloadFailureFromForItem": "Undertekster kunne ikke hentes fra {0} til {1}",
"Sync": "Synkroniser",
"System": "System",
diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json
index 8d86b2da1..e9a1630d9 100644
--- a/Emby.Server.Implementations/Localization/Core/de.json
+++ b/Emby.Server.Implementations/Localization/Core/de.json
@@ -61,7 +61,7 @@
"NotificationOptionVideoPlayback": "Video wird abgespielt",
"NotificationOptionVideoPlaybackStopped": "Videowiedergabe gestoppt",
"Photos": "Fotos",
- "Playlists": "Playlists",
+ "Playlists": "Wiedergabelisten",
"Plugin": "Plugin",
"PluginInstalledWithName": "{0} wurde installiert",
"PluginUninstalledWithName": "{0} wurde deinstalliert",
@@ -73,7 +73,6 @@
"Shows": "Serien",
"Songs": "Lieder",
"StartupEmbyServerIsLoading": "Jellyfin-Server lädt. Bitte versuche es gleich noch einmal.",
- "SubtitleDownloadFailureForItem": "Download der Untertitel fehlgeschlagen für {0}",
"SubtitleDownloadFailureFromForItem": "Untertitel von {0} für {1} konnten nicht heruntergeladen werden",
"Sync": "Synchronisation",
"System": "System",
@@ -96,20 +95,20 @@
"TaskDownloadMissingSubtitles": "Fehlende Untertitel herunterladen",
"TaskRefreshChannelsDescription": "Aktualisiert Internet-Kanal-Informationen.",
"TaskRefreshChannels": "Kanäle aktualisieren",
- "TaskCleanTranscodeDescription": "Löscht temporäre Videodateien, die älter als 24 Stunden sind.",
- "TaskCleanTranscode": "Temporäre Videodateien löschen",
+ "TaskCleanTranscodeDescription": "Löscht Transkodierungsdateien, die älter als einen Tag sind.",
+ "TaskCleanTranscode": "Transkodierungsverzeichnis leeren",
"TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche für automatische Updates konfiguriert sind und installiert diese.",
"TaskUpdatePlugins": "Plugins aktualisieren",
"TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.",
"TaskRefreshPeople": "Personen aktualisieren",
"TaskCleanLogsDescription": "Lösche Log-Dateien, die älter als {0} Tage sind.",
- "TaskCleanLogs": "Alte Protokolle löschen",
- "TaskRefreshLibraryDescription": "Sucht nach neuen Dateien und aktualisiert Infos zu deinen Medien.",
+ "TaskCleanLogs": "Protokollverzeichnis leeren",
+ "TaskRefreshLibraryDescription": "Durchsucht deine Medienbibliothek nach neuen Dateien und aktualisiert Metadaten.",
"TaskRefreshLibrary": "Medien-Bibliothek scannen",
"TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videokapitel.",
"TaskRefreshChapterImages": "Kapitelvorschauen erstellen",
- "TaskCleanCacheDescription": "Entfernt nicht mehr benötigte Cache-Dateien.",
- "TaskCleanCache": "Cache leeren",
+ "TaskCleanCacheDescription": "Löscht Cache-Dateien, die vom System nicht mehr benötigt werden.",
+ "TaskCleanCache": "Cache-Verzeichnis leeren",
"TasksChannelsCategory": "Internet-Kanäle",
"TasksApplicationCategory": "Anwendung",
"TasksLibraryCategory": "Bibliothek",
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index 2ba2085da..87362ff8e 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -73,7 +73,6 @@
"Shows": "Σειρές",
"Songs": "Τραγούδια",
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
- "SubtitleDownloadFailureForItem": "Οι υπότιτλοι απέτυχαν να κατέβουν για {0}",
"SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
"Sync": "Συγχρονισμός",
"System": "Σύστημα",
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index 720f550b3..bd5be0b1f 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -73,7 +73,6 @@
"Shows": "Shows",
"Songs": "Songs",
"StartupEmbyServerIsLoading": "Jellyfin Server is loading. Please try again shortly.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Subtitles failed to download from {0} for {1}",
"Sync": "Sync",
"System": "System",
diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json
index 1f8af4c8a..2bbf0d514 100644
--- a/Emby.Server.Implementations/Localization/Core/es-AR.json
+++ b/Emby.Server.Implementations/Localization/Core/es-AR.json
@@ -20,7 +20,7 @@
"HeaderFavoriteAlbums": "Álbumes favoritos",
"HeaderFavoriteArtists": "Artistas favoritos",
"HeaderFavoriteEpisodes": "Capítulos favoritos",
- "HeaderFavoriteShows": "Programas favoritos",
+ "HeaderFavoriteShows": "Series favoritas",
"HeaderFavoriteSongs": "Canciones favoritas",
"HeaderLiveTV": "TV en vivo",
"HeaderNextUp": "Siguiente",
@@ -73,7 +73,6 @@
"Shows": "Series",
"Songs": "Canciones",
"StartupEmbyServerIsLoading": "El servidor Jellyfin se está cargando. Vuelve a intentarlo en breve.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Falló la descarga de subtitulos desde {0} para {1}",
"Sync": "Sincronizar",
"System": "Sistema",
diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json
index 2830c657b..6748fff4c 100644
--- a/Emby.Server.Implementations/Localization/Core/es-MX.json
+++ b/Emby.Server.Implementations/Localization/Core/es-MX.json
@@ -73,7 +73,6 @@
"Shows": "Programas",
"Songs": "Canciones",
"StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo pronto.",
- "SubtitleDownloadFailureForItem": "Falló la descarga de subtítulos para {0}",
"SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}",
"Sync": "Sincronizar",
"System": "Sistema",
diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json
index 1ec5eaa2a..b9c57afe6 100644
--- a/Emby.Server.Implementations/Localization/Core/es.json
+++ b/Emby.Server.Implementations/Localization/Core/es.json
@@ -73,7 +73,6 @@
"Shows": "Series",
"Songs": "Canciones",
"StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.",
- "SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}",
"SubtitleDownloadFailureFromForItem": "Fallo en la descarga de subtítulos desde {0} para {1}",
"Sync": "Sincronizar",
"System": "Sistema",
diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json
index 2e692009b..91a0aa663 100644
--- a/Emby.Server.Implementations/Localization/Core/et.json
+++ b/Emby.Server.Implementations/Localization/Core/et.json
@@ -72,7 +72,7 @@
"NotificationOptionApplicationUpdateAvailable": "Rakenduse uuendus on saadaval",
"NewVersionIsAvailable": "Jellyfin serveri uus versioon on allalaadimiseks saadaval.",
"NameSeasonUnknown": "Tundmatu hooaeg",
- "NameSeasonNumber": "Hooaeg {0}",
+ "NameSeasonNumber": "{0}. hooaeg",
"NameInstallFailed": "{0} paigaldamine nurjus",
"MusicVideos": "Muusikavideod",
"Music": "Muusika",
diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json
index ff14c1367..90cd3a58e 100644
--- a/Emby.Server.Implementations/Localization/Core/fa.json
+++ b/Emby.Server.Implementations/Localization/Core/fa.json
@@ -73,7 +73,6 @@
"Shows": "سریال‌ها",
"Songs": "موسیقی‌ها",
"StartupEmbyServerIsLoading": "سرور Jellyfin در حال بارگیری است. لطفا کمی بعد دوباره تلاش کنید.",
- "SubtitleDownloadFailureForItem": "دانلود زیرنویس برای {0} ناموفق بود",
"SubtitleDownloadFailureFromForItem": "بارگیری زیرنویس برای {1} از {0} شکست خورد",
"Sync": "همگام‌سازی",
"System": "سیستم",
diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json
index 6d079d2f5..a8964e8b6 100644
--- a/Emby.Server.Implementations/Localization/Core/fr-CA.json
+++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json
@@ -73,7 +73,6 @@
"Shows": "Séries",
"Songs": "Chansons",
"StartupEmbyServerIsLoading": "Serveur Jellyfin en cours de chargement. Réessayez dans quelques instants.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}",
"Sync": "Synchroniser",
"System": "Système",
diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json
index 8bf41c02a..b2a2e502a 100644
--- a/Emby.Server.Implementations/Localization/Core/fr.json
+++ b/Emby.Server.Implementations/Localization/Core/fr.json
@@ -73,7 +73,6 @@
"Shows": "Séries",
"Songs": "Chansons",
"StartupEmbyServerIsLoading": "Le serveur Jellyfin est en cours de chargement. Veuillez réessayer dans quelques instants.",
- "SubtitleDownloadFailureForItem": "Le téléchargement des sous-titres pour {0} a échoué.",
"SubtitleDownloadFailureFromForItem": "Échec du téléchargement des sous-titres depuis {0} pour {1}",
"Sync": "Synchroniser",
"System": "Système",
diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json
index e1ee8cf7c..9be6f05ee 100644
--- a/Emby.Server.Implementations/Localization/Core/gsw.json
+++ b/Emby.Server.Implementations/Localization/Core/gsw.json
@@ -73,7 +73,6 @@
"Shows": "Serie",
"Songs": "Lieder",
"StartupEmbyServerIsLoading": "Jellyfin Server ladt. Bitte grad noeinisch probiere.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Ondertetle vo {0} för {1} hend ned chönne abeglade wärde",
"Sync": "Synchronisation",
"System": "System",
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index 90c921898..ef95a639f 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -73,7 +73,6 @@
"Shows": "סדרות",
"Songs": "שירים",
"StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה",
"Sync": "סנכרון",
"System": "מערכת",
diff --git a/Emby.Server.Implementations/Localization/Core/he_IL.json b/Emby.Server.Implementations/Localization/Core/he_IL.json
index 0967ef424..e8812c8a1 100644
--- a/Emby.Server.Implementations/Localization/Core/he_IL.json
+++ b/Emby.Server.Implementations/Localization/Core/he_IL.json
@@ -1 +1,27 @@
-{}
+{
+ "Books": "ספרים",
+ "NameSeasonNumber": "עונה {0}",
+ "Channels": "ערוצים",
+ "Movies": "סרטים",
+ "Music": "מוזיקה",
+ "Collections": "אוספים",
+ "Albums": "אלבומים",
+ "Application": "אפליקציה",
+ "Artists": "אמנים",
+ "ChapterNameValue": "פרק {0}",
+ "External": "חיצונית",
+ "Favorites": "מועדפים",
+ "Folders": "תיקיות",
+ "Genres": "ז'אנרים",
+ "HeaderAlbumArtists": "אמני אלבומים",
+ "HeaderContinueWatching": "להמשיך לצפות",
+ "HeaderFavoriteAlbums": "אלבומים אהובים",
+ "HeaderFavoriteArtists": "אמנים אהובים",
+ "HeaderFavoriteEpisodes": "פרקים אהובים",
+ "HeaderFavoriteShows": "תוכניות אהובות",
+ "HeaderFavoriteSongs": "שירים אהובים",
+ "HeaderLiveTV": "טלוויזיה בשידור חי",
+ "HeaderNextUp": "הבא",
+ "HearingImpaired": "ללקויי שמיעה",
+ "HomeVideos": "סרטונים ביתיים"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index 67263d3b2..94db43571 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -8,7 +8,7 @@
"CameraImageUploadedFrom": "Nova fotografija sa kamere je učitana iz {0}",
"Channels": "Kanali",
"ChapterNameValue": "Poglavlje {0}",
- "Collections": "Kolekcije",
+ "Collections": "Zbirke",
"DeviceOfflineWithName": "{0} je prekinuo vezu",
"DeviceOnlineWithName": "{0} je povezan",
"FailedLoginAttemptWithUserName": "Neuspješan pokušaj prijave od {0}",
@@ -70,10 +70,9 @@
"ScheduledTaskFailedWithName": "{0} neuspjelo",
"ScheduledTaskStartedWithName": "{0} pokrenuto",
"ServerNameNeedsToBeRestarted": "{0} treba ponovno pokrenuti",
- "Shows": "Serije",
+ "Shows": "Emisije",
"Songs": "Pjesme",
"StartupEmbyServerIsLoading": "Jellyfin server se učitava. Pokušajte ponovo uskoro.",
- "SubtitleDownloadFailureForItem": "Titlovi prijevoda nisu preuzeti za {0}",
"SubtitleDownloadFailureFromForItem": "Prijevod nije uspješno preuzet od {0} za {1}",
"Sync": "Sinkronizacija",
"System": "Sustav",
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
index 81a996330..7d72c1f30 100644
--- a/Emby.Server.Implementations/Localization/Core/hu.json
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -55,7 +55,7 @@
"NotificationOptionPluginInstalled": "Bővítmény telepítve",
"NotificationOptionPluginUninstalled": "Bővítmény eltávolítva",
"NotificationOptionPluginUpdateInstalled": "Bővítményfrissítés telepítve",
- "NotificationOptionServerRestartRequired": "A kiszolgáló újraindítása szükséges",
+ "NotificationOptionServerRestartRequired": "A szerver újraindítása szükséges",
"NotificationOptionTaskFailed": "Hiba az ütemezett feladatban",
"NotificationOptionUserLockedOut": "Felhasználó tiltva",
"NotificationOptionVideoPlayback": "Videólejátszás elkezdve",
@@ -73,7 +73,6 @@
"Shows": "Sorozatok",
"Songs": "Számok",
"StartupEmbyServerIsLoading": "A Jellyfin kiszolgáló betöltődik. Próbálja újra hamarosan.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0}, ehhez: {1}",
"Sync": "Szinkronizálás",
"System": "Rendszer",
diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json
index 421c4ee30..f0c4b5027 100644
--- a/Emby.Server.Implementations/Localization/Core/it.json
+++ b/Emby.Server.Implementations/Localization/Core/it.json
@@ -3,7 +3,7 @@
"AppDeviceValues": "App: {0}, Dispositivo: {1}",
"Application": "Applicazione",
"Artists": "Artisti",
- "AuthenticationSucceededWithUserName": "{0} autenticato con successo",
+ "AuthenticationSucceededWithUserName": "{0} autenticato correttamente",
"Books": "Libri",
"CameraImageUploadedFrom": "È stata caricata una nuova fotografia da {0}",
"Channels": "Canali",
@@ -11,36 +11,36 @@
"Collections": "Collezioni",
"DeviceOfflineWithName": "{0} si è disconnesso",
"DeviceOnlineWithName": "{0} è connesso",
- "FailedLoginAttemptWithUserName": "Tentativo di accesso fallito da {0}",
+ "FailedLoginAttemptWithUserName": "Tentativo di accesso non riuscito da {0}",
"Favorites": "Preferiti",
"Folders": "Cartelle",
"Genres": "Generi",
"HeaderAlbumArtists": "Artisti dell'album",
"HeaderContinueWatching": "Continua a guardare",
- "HeaderFavoriteAlbums": "Album Preferiti",
- "HeaderFavoriteArtists": "Artisti Preferiti",
- "HeaderFavoriteEpisodes": "Episodi Preferiti",
- "HeaderFavoriteShows": "Serie TV Preferite",
- "HeaderFavoriteSongs": "Brani Preferiti",
+ "HeaderFavoriteAlbums": "Album preferiti",
+ "HeaderFavoriteArtists": "Artisti preferiti",
+ "HeaderFavoriteEpisodes": "Episodi preferiti",
+ "HeaderFavoriteShows": "Serie TV preferite",
+ "HeaderFavoriteSongs": "Brani preferiti",
"HeaderLiveTV": "Diretta TV",
"HeaderNextUp": "Prossimo",
- "HeaderRecordingGroups": "Gruppi di Registrazione",
- "HomeVideos": "Video Personali",
+ "HeaderRecordingGroups": "Gruppi di registrazione",
+ "HomeVideos": "Video personali",
"Inherit": "Eredita",
"ItemAddedWithName": "{0} è stato aggiunto alla libreria",
"ItemRemovedWithName": "{0} è stato rimosso dalla libreria",
"LabelIpAddressValue": "Indirizzo IP: {0}",
"LabelRunningTimeValue": "Durata: {0}",
"Latest": "Novità",
- "MessageApplicationUpdated": "Il Server Jellyfin è stato aggiornato",
+ "MessageApplicationUpdated": "Jellyfin Server è stato aggiornato",
"MessageApplicationUpdatedTo": "Jellyfin Server è stato aggiornato a {0}",
"MessageNamedServerConfigurationUpdatedWithValue": "La sezione {0} della configurazione server è stata aggiornata",
"MessageServerConfigurationUpdated": "La configurazione del server è stata aggiornata",
"MixedContent": "Contenuto misto",
"Movies": "Film",
"Music": "Musica",
- "MusicVideos": "Video Musicali",
- "NameInstallFailed": "{0} installazione fallita",
+ "MusicVideos": "Video musicali",
+ "NameInstallFailed": "{0} installazione non riuscita",
"NameSeasonNumber": "Stagione {0}",
"NameSeasonUnknown": "Stagione sconosciuta",
"NewVersionIsAvailable": "Una nuova versione di Jellyfin Server è disponibile per il download.",
@@ -49,38 +49,37 @@
"NotificationOptionAudioPlayback": "La riproduzione audio è iniziata",
"NotificationOptionAudioPlaybackStopped": "La riproduzione audio è stata interrotta",
"NotificationOptionCameraImageUploaded": "Immagine fotocamera caricata",
- "NotificationOptionInstallationFailed": "Installazione fallita",
+ "NotificationOptionInstallationFailed": "Installazione non riuscita",
"NotificationOptionNewLibraryContent": "Nuovo contenuto aggiunto",
"NotificationOptionPluginError": "Errore del plugin",
"NotificationOptionPluginInstalled": "Plugin installato",
"NotificationOptionPluginUninstalled": "Plugin disinstallato",
"NotificationOptionPluginUpdateInstalled": "Aggiornamento plugin installato",
"NotificationOptionServerRestartRequired": "Riavvio del server necessario",
- "NotificationOptionTaskFailed": "Operazione pianificata fallita",
+ "NotificationOptionTaskFailed": "Operazione pianificata non riuscita",
"NotificationOptionUserLockedOut": "Utente bloccato",
"NotificationOptionVideoPlayback": "Riproduzione video iniziata",
"NotificationOptionVideoPlaybackStopped": "Riproduzione video interrotta",
"Photos": "Foto",
- "Playlists": "Playlist",
+ "Playlists": "Scalette",
"Plugin": "Plugin",
- "PluginInstalledWithName": "{0} è stato Installato",
+ "PluginInstalledWithName": "{0} è stato installato",
"PluginUninstalledWithName": "{0} è stato disinstallato",
"PluginUpdatedWithName": "{0} è stato aggiornato",
"ProviderValue": "Provider: {0}",
- "ScheduledTaskFailedWithName": "{0} fallito",
+ "ScheduledTaskFailedWithName": "{0} non riuscito",
"ScheduledTaskStartedWithName": "{0} avviato",
"ServerNameNeedsToBeRestarted": "{0} deve essere riavviato",
"Shows": "Serie TV",
"Songs": "Brani",
- "StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.",
- "SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}",
+ "StartupEmbyServerIsLoading": "Jellyfin Server si sta avviando. Riprova più tardi.",
"SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}",
"Sync": "Sincronizza",
"System": "Sistema",
"TvShows": "Serie TV",
"User": "Utente",
"UserCreatedWithName": "L'utente {0} è stato creato",
- "UserDeletedWithName": "L'utente {0} è stato rimosso",
+ "UserDeletedWithName": "L'utente {0} è stato eliminato",
"UserDownloadingItemWithValues": "{0} sta scaricando {1}",
"UserLockedOutWithName": "L'utente {0} è stato bloccato",
"UserOfflineFromDevice": "{0} si è disconnesso da {1}",
@@ -115,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.",
- "Undefined": "Non Definito",
+ "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",
+ "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/Localization/Core/kk.json b/Emby.Server.Implementations/Localization/Core/kk.json
index e050196bc..fc5fcf3c4 100644
--- a/Emby.Server.Implementations/Localization/Core/kk.json
+++ b/Emby.Server.Implementations/Localization/Core/kk.json
@@ -73,7 +73,6 @@
"Shows": "Körsetımder",
"Songs": "Äuender",
"StartupEmbyServerIsLoading": "Jellyfin Server jüktelude. Ärekettı köp ūzamai qaitalañyz.",
- "SubtitleDownloadFailureForItem": "Субтитрлер {0} үшін жүктеліп алынуы сәтсіз",
"SubtitleDownloadFailureFromForItem": "{1} üşın subtitrlerdı {0} közınen jüktep alu sätsız",
"Sync": "Ündestıru",
"System": "Jüie",
diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json
index 3d1b1ed27..2b24ea2c8 100644
--- a/Emby.Server.Implementations/Localization/Core/ko.json
+++ b/Emby.Server.Implementations/Localization/Core/ko.json
@@ -73,7 +73,6 @@
"Shows": "시리즈",
"Songs": "노래",
"StartupEmbyServerIsLoading": "Jellyfin 서버를 불러오고 있습니다. 잠시 후에 다시 시도하십시오.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "{0}에서 {1} 자막 다운로드에 실패했습니다",
"Sync": "동기화",
"System": "시스템",
diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json
index 3918ab81c..bdf63b4ca 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -73,7 +73,6 @@
"Shows": "Laidos",
"Songs": "Kūriniai",
"StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}",
"Sync": "Sinchronizuoti",
"System": "Sistema",
diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json
index 5c3449381..8c20ded3a 100644
--- a/Emby.Server.Implementations/Localization/Core/ml.json
+++ b/Emby.Server.Implementations/Localization/Core/ml.json
@@ -2,12 +2,12 @@
"AppDeviceValues": "അപ്ലിക്കേഷൻ: {0}, ഉപകരണം: {1}",
"Application": "അപ്ലിക്കേഷൻ",
"AuthenticationSucceededWithUserName": "{0} വിജയകരമായി പ്രാമാണീകരിച്ചു",
- "CameraImageUploadedFrom": "Camera 0 from എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്‌ലോഡുചെയ്‌തു",
+ "CameraImageUploadedFrom": "{0} എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്‌ലോഡുചെയ്‌തു",
"ChapterNameValue": "അധ്യായം {0}",
"DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു",
"DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു",
"FailedLoginAttemptWithUserName": "{0}ൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു",
- "Forced": "നിർബന്ധിച്ചു",
+ "Forced": "നിർബന്ധിതമായി",
"HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ",
"HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ",
"HeaderFavoriteEpisodes": "പ്രിയപ്പെട്ട എപ്പിസോഡുകൾ",
@@ -114,7 +114,7 @@
"Artists": "കലാകാരന്മാർ",
"Shows": "ഷോകൾ",
"Default": "സ്ഥിരസ്ഥിതി",
- "Favorites": "പ്രിയങ്കരങ്ങൾ",
+ "Favorites": "പ്രിയപ്പെട്ടവ",
"Books": "പുസ്തകങ്ങൾ",
"Genres": "വിഭാഗങ്ങൾ",
"Channels": "ചാനലുകൾ",
diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json
index 971f79c2c..2be04be80 100644
--- a/Emby.Server.Implementations/Localization/Core/ms.json
+++ b/Emby.Server.Implementations/Localization/Core/ms.json
@@ -73,7 +73,6 @@
"Shows": "Tayangan",
"Songs": "Lagu-lagu",
"StartupEmbyServerIsLoading": "Pelayan Jellyfin sedang dimuatkan. Sila cuba sebentar lagi.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Muat turun sarikata gagal dari {0} untuk {1}",
"Sync": "Segerak",
"System": "Sistem",
diff --git a/Emby.Server.Implementations/Localization/Core/my.json b/Emby.Server.Implementations/Localization/Core/my.json
index 4cb4cdc75..097d0d2fb 100644
--- a/Emby.Server.Implementations/Localization/Core/my.json
+++ b/Emby.Server.Implementations/Localization/Core/my.json
@@ -126,5 +126,7 @@
"TaskRefreshTrickplayImages": "ထရစ်ခ်ပလေး ပုံများကို ထုတ်မည်",
"TaskKeyframeExtractor": "ကီးဖရိန်များကို ထုတ်နုတ်ခြင်း",
"TaskCleanCollectionsAndPlaylists": "စုစည်းမှုများနှင့် အစဉ်လိုက်ပြသမှုများကို ရှင်းလင်းမည်",
- "HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ"
+ "HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ",
+ "TaskDownloadMissingLyrics": "ကျန်နေသောသီချင်းစာသားများအား ဒေါင်းလုတ်ဆွဲပါ",
+ "TaskDownloadMissingLyricsDescription": "သီချင်းများအတွက် သီချင်းစာသား ဒေါင်းလုတ်ဆွဲပါ"
}
diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json
index e73c56cb9..cd0315720 100644
--- a/Emby.Server.Implementations/Localization/Core/nb.json
+++ b/Emby.Server.Implementations/Localization/Core/nb.json
@@ -73,7 +73,6 @@
"Shows": "Serier",
"Songs": "Sanger",
"StartupEmbyServerIsLoading": "Jellyfin Server laster. Prøv igjen snart.",
- "SubtitleDownloadFailureForItem": "En feil oppstå under nedlasting av undertekster for {0}",
"SubtitleDownloadFailureFromForItem": "Kunne ikke laste ned undertekster fra {0} for {1}",
"Sync": "Synkroniser",
"System": "System",
diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json
index 09246bd11..534c64e93 100644
--- a/Emby.Server.Implementations/Localization/Core/nl.json
+++ b/Emby.Server.Implementations/Localization/Core/nl.json
@@ -73,7 +73,6 @@
"Shows": "Series",
"Songs": "Nummers",
"StartupEmbyServerIsLoading": "Jellyfin Server is aan het laden. Probeer het later opnieuw.",
- "SubtitleDownloadFailureForItem": "Downloaden van ondertiteling voor {0} is mislukt",
"SubtitleDownloadFailureFromForItem": "Ondertiteling kon niet gedownload worden van {0} voor {1}",
"Sync": "Synchronisatie",
"System": "Systeem",
diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json
index 8ca22ac04..f1c19ac1d 100644
--- a/Emby.Server.Implementations/Localization/Core/pl.json
+++ b/Emby.Server.Implementations/Localization/Core/pl.json
@@ -73,7 +73,6 @@
"Shows": "Seriale",
"Songs": "Utwory",
"StartupEmbyServerIsLoading": "Trwa wczytywanie serwera Jellyfin. Spróbuj ponownie za chwilę.",
- "SubtitleDownloadFailureForItem": "Pobieranie napisów dla {0} zakończone niepowodzeniem",
"SubtitleDownloadFailureFromForItem": "Nieudane pobieranie napisów z {0} dla {1}",
"Sync": "Synchronizacja",
"System": "System",
diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json
index dc5bff161..8e76c6c63 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-BR.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json
@@ -73,7 +73,6 @@
"Shows": "Séries",
"Songs": "Músicas",
"StartupEmbyServerIsLoading": "O Servidor Jellyfin está carregando. Por favor, tente novamente mais tarde.",
- "SubtitleDownloadFailureForItem": "Download de legendas falhou para {0}",
"SubtitleDownloadFailureFromForItem": "Houve um problema ao baixar as legendas de {0} para {1}",
"Sync": "Sincronizar",
"System": "Sistema",
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index 17284854f..c2ce2ba40 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -73,7 +73,6 @@
"Shows": "Séries",
"Songs": "Músicas",
"StartupEmbyServerIsLoading": "O servidor Jellyfin está a iniciar. Tente novamente mais tarde.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}",
"Sync": "Sincronização",
"System": "Sistema",
@@ -125,8 +124,8 @@
"TaskKeyframeExtractor": "Extrator de Quadros-chave",
"External": "Externo",
"HearingImpaired": "Surdo",
- "TaskRefreshTrickplayImages": "Gerar Imagens de Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Cria ficheiros de trickplay para vídeos nas bibliotecas ativas.",
+ "TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
+ "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
"TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
"TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução",
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json
index 74bb1c63a..9ae346e25 100644
--- a/Emby.Server.Implementations/Localization/Core/pt.json
+++ b/Emby.Server.Implementations/Localization/Core/pt.json
@@ -124,8 +124,8 @@
"HearingImpaired": "Problemas auditivos",
"TaskKeyframeExtractor": "Extrator de quadro-chave",
"TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
- "TaskRefreshTrickplayImages": "Gerar miniaturas de vídeo",
- "TaskRefreshTrickplayImagesDescription": "Cria miniaturas de vídeo para vídeos nas bibliotecas definidas.",
+ "TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
+ "TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
"TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
"TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução",
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json
index 1470a538c..03bce0ebd 100644
--- a/Emby.Server.Implementations/Localization/Core/ru.json
+++ b/Emby.Server.Implementations/Localization/Core/ru.json
@@ -73,7 +73,6 @@
"Shows": "Сериалы",
"Songs": "Композиции",
"StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.",
- "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить",
"SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}",
"Sync": "Синхронизация",
"System": "Система",
diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index 1de78eeae..7c8d86047 100644
--- a/Emby.Server.Implementations/Localization/Core/sk.json
+++ b/Emby.Server.Implementations/Localization/Core/sk.json
@@ -73,7 +73,6 @@
"Shows": "Seriály",
"Songs": "Skladby",
"StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.",
- "SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo",
"SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo",
"Sync": "Synchronizácia",
"System": "Systém",
diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json
index ff92db2f2..7c7c88e28 100644
--- a/Emby.Server.Implementations/Localization/Core/sl-SI.json
+++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json
@@ -73,7 +73,6 @@
"Shows": "Serije",
"Songs": "Pesmi",
"StartupEmbyServerIsLoading": "Jellyfin strežnik se zaganja. Poskusite ponovno kasneje.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Neuspešen prenos podnapisov iz {0} za {1}",
"Sync": "Sinhroniziraj",
"System": "Sistem",
diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json
index 1ee1a5366..23acd3c53 100644
--- a/Emby.Server.Implementations/Localization/Core/sv.json
+++ b/Emby.Server.Implementations/Localization/Core/sv.json
@@ -73,7 +73,6 @@
"Shows": "Serier",
"Songs": "Låtar",
"StartupEmbyServerIsLoading": "Jellyfin Server arbetar. Pröva igen snart.",
- "SubtitleDownloadFailureForItem": "Nerladdning av undertexter för {0} misslyckades",
"SubtitleDownloadFailureFromForItem": "Undertexter kunde inte laddas ner från {0} till {1}",
"Sync": "Synk",
"System": "System",
diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json
index 113e4f30f..65ddb55e9 100644
--- a/Emby.Server.Implementations/Localization/Core/th.json
+++ b/Emby.Server.Implementations/Localization/Core/th.json
@@ -135,5 +135,7 @@
"TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย",
"TaskMoveTrickplayImagesDescription": "ย้ายไฟล์ Trickplay ตามการตั้งค่าของไลบรารี",
"TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment",
- "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay"
+ "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
+ "CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
+ "CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน"
}
diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json
index 478111049..d13f662e4 100644
--- a/Emby.Server.Implementations/Localization/Core/tr.json
+++ b/Emby.Server.Implementations/Localization/Core/tr.json
@@ -30,7 +30,7 @@
"ItemAddedWithName": "{0} kütüphaneye eklendi",
"ItemRemovedWithName": "{0} kütüphaneden silindi",
"LabelIpAddressValue": "IP adresi: {0}",
- "LabelRunningTimeValue": "Çalışma süresi: {0}",
+ "LabelRunningTimeValue": "Oynatma süresi: {0}",
"Latest": "En son",
"MessageApplicationUpdated": "Jellyfin Sunucusu güncellendi",
"MessageApplicationUpdatedTo": "Jellyfin Sunucusu {0} sürümüne güncellendi",
@@ -42,7 +42,7 @@
"MusicVideos": "Müzik Videoları",
"NameInstallFailed": "{0} kurulumu başarısız",
"NameSeasonNumber": "{0}. Sezon",
- "NameSeasonUnknown": "Bilinmeyen Sezon",
+ "NameSeasonUnknown": "Sezon Bilinmiyor",
"NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir sürümü indirmek için hazır.",
"NotificationOptionApplicationUpdateAvailable": "Uygulama güncellemesi mevcut",
"NotificationOptionApplicationUpdateInstalled": "Uygulama güncellemesi yüklendi",
@@ -57,7 +57,7 @@
"NotificationOptionPluginUpdateInstalled": "Eklenti güncellemesi yüklendi",
"NotificationOptionServerRestartRequired": "Sunucunun yeniden başlatılması gerekiyor",
"NotificationOptionTaskFailed": "Zamanlanmış görev hatası",
- "NotificationOptionUserLockedOut": "Kullanıcı kilitlendi",
+ "NotificationOptionUserLockedOut": "Kullanıcı hesabı kilitlendi",
"NotificationOptionVideoPlayback": "Video oynatma başladı",
"NotificationOptionVideoPlaybackStopped": "Video oynatma durduruldu",
"Photos": "Fotoğraflar",
@@ -73,8 +73,7 @@
"Shows": "Diziler",
"Songs": "Şarkılar",
"StartupEmbyServerIsLoading": "Jellyfin Sunucusu yükleniyor. Lütfen kısa süre sonra tekrar deneyin.",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
- "SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} sağlayıcısından indirilemedi",
+ "SubtitleDownloadFailureFromForItem": "{1} için altyazılar {0} sağlayıcısından indirilemedi",
"Sync": "Eşzamanlama",
"System": "Sistem",
"TvShows": "Diziler",
@@ -82,7 +81,7 @@
"UserCreatedWithName": "{0} kullanıcısı oluşturuldu",
"UserDeletedWithName": "{0} kullanıcısı silindi",
"UserDownloadingItemWithValues": "{0} kullanıcısı {1} medyasını indiriyor",
- "UserLockedOutWithName": "{0} adlı kullanıcı kilitlendi",
+ "UserLockedOutWithName": "{0} adlı kullanıcı hesabı kilitlendi",
"UserOfflineFromDevice": "{0} kullanıcısının {1} ile bağlantısı kesildi",
"UserOnlineFromDevice": "{0} kullanıcısı {1} ile çevrimiçi",
"UserPasswordChangedWithName": "{0} kullanıcısının parolası değiştirildi",
@@ -98,8 +97,8 @@
"TasksLibraryCategory": "Kütüphane",
"TasksMaintenanceCategory": "Bakım",
"TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.",
- "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik alt yazılar için internette arama yapar.",
- "TaskDownloadMissingSubtitles": "Eksik alt yazıları indir",
+ "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik altyazılar için internette arama yapar.",
+ "TaskDownloadMissingSubtitles": "Eksik altyazıları indir",
"TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.",
"TaskRefreshChannels": "Kanalları Yenile",
"TaskCleanTranscodeDescription": "Bir günden daha eski kod dönüştürme dosyalarını siler.",
@@ -125,15 +124,15 @@
"TaskKeyframeExtractor": "Ana Kare Çıkarıcı",
"External": "Harici",
"HearingImpaired": "Duyma Engelli",
- "TaskRefreshTrickplayImages": "Trickplay Görselleri Oluştur",
- "TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için trickplay önizlemeleri oluşturur.",
+ "TaskRefreshTrickplayImages": "Hızlı Önizleme Görsellerini Oluştur",
+ "TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için hızlı önizleme görselleri oluşturur.",
"TaskCleanCollectionsAndPlaylistsDescription": "Artık var olmayan koleksiyon ve çalma listelerindeki ögeleri kaldırır.",
"TaskCleanCollectionsAndPlaylists": "Koleksiyonları ve çalma listelerini temizleyin",
"TaskAudioNormalizationDescription": "Ses normalleştirme verileri için dosyaları tarar.",
"TaskAudioNormalization": "Ses Normalleştirme",
"TaskExtractMediaSegments": "Medya Segmenti Tarama",
- "TaskMoveTrickplayImages": "Trickplay Görsel Konumunu Taşıma",
- "TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.",
+ "TaskMoveTrickplayImages": "Hızlı Önizleme Görsel Konumunu Taşıma",
+ "TaskMoveTrickplayImagesDescription": "Mevcut hızlı önizleme dosyalarını kütüphane ayarlarına göre taşır.",
"TaskDownloadMissingLyrics": "Eksik şarkı sözlerini indir",
"TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir",
"TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır.",
diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json
index 1bfa4e3c3..0a0795d41 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-CN.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json
@@ -5,60 +5,60 @@
"Artists": "艺术家",
"AuthenticationSucceededWithUserName": "{0} 认证成功",
"Books": "书籍",
- "CameraImageUploadedFrom": "新的相机图像已从 {0} 上传",
+ "CameraImageUploadedFrom": "已从 {0} 上传新的相机照片",
"Channels": "频道",
"ChapterNameValue": "章节 {0}",
"Collections": "合集",
- "DeviceOfflineWithName": "{0} 已断开",
+ "DeviceOfflineWithName": "{0} 已断开连接",
"DeviceOnlineWithName": "{0} 已连接",
- "FailedLoginAttemptWithUserName": "来自 {0} 的登录尝试失败",
- "Favorites": "我的最爱",
+ "FailedLoginAttemptWithUserName": "来自 {0} 的登录失败",
+ "Favorites": "收藏夹",
"Folders": "文件夹",
"Genres": "类型",
"HeaderAlbumArtists": "专辑艺术家",
"HeaderContinueWatching": "继续观看",
"HeaderFavoriteAlbums": "收藏的专辑",
- "HeaderFavoriteArtists": "最爱的艺术家",
- "HeaderFavoriteEpisodes": "最爱的剧集",
- "HeaderFavoriteShows": "最爱的节目",
- "HeaderFavoriteSongs": "最爱的歌曲",
+ "HeaderFavoriteArtists": "收藏的艺术家",
+ "HeaderFavoriteEpisodes": "收藏的剧集",
+ "HeaderFavoriteShows": "收藏的节目",
+ "HeaderFavoriteSongs": "收藏的歌曲",
"HeaderLiveTV": "电视直播",
- "HeaderNextUp": "接下来",
+ "HeaderNextUp": "接下来播放",
"HeaderRecordingGroups": "录制组",
"HomeVideos": "家庭视频",
"Inherit": "继承",
"ItemAddedWithName": "{0} 已添加到媒体库",
- "ItemRemovedWithName": "{0} 已从媒体库中移除",
+ "ItemRemovedWithName": "{0} 已从媒体库移除",
"LabelIpAddressValue": "IP 地址:{0}",
"LabelRunningTimeValue": "运行时间:{0}",
"Latest": "最新",
"MessageApplicationUpdated": "Jellyfin 服务器已更新",
- "MessageApplicationUpdatedTo": "Jellyfin Server 版本已更新为 {0}",
+ "MessageApplicationUpdatedTo": "Jellyfin 服务器版本已更新到 {0}",
"MessageNamedServerConfigurationUpdatedWithValue": "服务器配置 {0} 部分已更新",
"MessageServerConfigurationUpdated": "服务器配置已更新",
"MixedContent": "混合内容",
"Movies": "电影",
"Music": "音乐",
- "MusicVideos": "音乐视频",
+ "MusicVideos": "MV",
"NameInstallFailed": "{0} 安装失败",
"NameSeasonNumber": "第 {0} 季",
"NameSeasonUnknown": "未知季",
- "NewVersionIsAvailable": "Jellyfin Server 有新版本可以下载。",
+ "NewVersionIsAvailable": "Jellyfin 服务器有新版本可供下载。",
"NotificationOptionApplicationUpdateAvailable": "有可用的应用程序更新",
"NotificationOptionApplicationUpdateInstalled": "应用程序更新已安装",
- "NotificationOptionAudioPlayback": "音频开始播放",
+ "NotificationOptionAudioPlayback": "音频已开始播放",
"NotificationOptionAudioPlaybackStopped": "音频播放已停止",
- "NotificationOptionCameraImageUploaded": "相机图片已上传",
+ "NotificationOptionCameraImageUploaded": "相机照片已上传",
"NotificationOptionInstallationFailed": "安装失败",
"NotificationOptionNewLibraryContent": "已添加新内容",
- "NotificationOptionPluginError": "插件失败",
+ "NotificationOptionPluginError": "插件出错",
"NotificationOptionPluginInstalled": "插件已安装",
"NotificationOptionPluginUninstalled": "插件已卸载",
- "NotificationOptionPluginUpdateInstalled": "插件更新已安装",
+ "NotificationOptionPluginUpdateInstalled": "插件已更新",
"NotificationOptionServerRestartRequired": "服务器需要重启",
"NotificationOptionTaskFailed": "计划任务失败",
- "NotificationOptionUserLockedOut": "用户已锁定",
- "NotificationOptionVideoPlayback": "视频开始播放",
+ "NotificationOptionUserLockedOut": "用户已被锁定",
+ "NotificationOptionVideoPlayback": "视频已开始播放",
"NotificationOptionVideoPlaybackStopped": "视频播放已停止",
"Photos": "照片",
"Playlists": "播放列表",
@@ -72,23 +72,22 @@
"ServerNameNeedsToBeRestarted": "{0} 需要重新启动",
"Shows": "节目",
"Songs": "歌曲",
- "StartupEmbyServerIsLoading": "Jellyfin 服务器加载中。请稍后再试。",
- "SubtitleDownloadFailureForItem": "为 {0} 下载字幕失败",
+ "StartupEmbyServerIsLoading": "Jellyfin 服务器正在启动,请稍后再试。",
"SubtitleDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的字幕",
"Sync": "同步",
"System": "系统",
"TvShows": "电视剧",
"User": "用户",
- "UserCreatedWithName": "用户 {0} 已创建",
- "UserDeletedWithName": "用户 {0} 已删除",
+ "UserCreatedWithName": "已创建用户 {0}",
+ "UserDeletedWithName": "已删除用户 {0}",
"UserDownloadingItemWithValues": "{0} 正在下载 {1}",
"UserLockedOutWithName": "用户 {0} 已被锁定",
"UserOfflineFromDevice": "{0} 已从 {1} 断开",
- "UserOnlineFromDevice": "{0} 在线,来自 {1}",
- "UserPasswordChangedWithName": "已为用户 {0} 更改密码",
- "UserPolicyUpdatedWithName": "用户协议已经被更新为 {0}",
- "UserStartedPlayingItemWithValues": "{0} 已在 {2} 上开始播放 {1}",
- "UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}",
+ "UserOnlineFromDevice": "{0} 已在 {1} 上线",
+ "UserPasswordChangedWithName": "用户 {0} 的密码已更改",
+ "UserPolicyUpdatedWithName": "用户协议已更新为 {0}",
+ "UserStartedPlayingItemWithValues": "{0} 在 {2} 上开始播放 {1}",
+ "UserStoppedPlayingItemWithValues": "{0} 在 {2} 上停止播放 {1}",
"ValueHasBeenAddedToLibrary": "{0} 已添加至您的媒体库中",
"ValueSpecialEpisodeName": "特典 - {0}",
"VersionNumber": "版本 {0}",
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index c8800e256..37ac79a29 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -73,7 +73,6 @@
"Shows": "節目",
"Songs": "歌曲",
"StartupEmbyServerIsLoading": "正在載入 Jellyfin,請稍後再試。",
- "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕",
"Sync": "同步",
"System": "系統",
@@ -86,7 +85,7 @@
"UserOfflineFromDevice": "{0} 終止了 {1} 的連接",
"UserOnlineFromDevice": "{0} 從 {1} 連線",
"UserPasswordChangedWithName": "{0} 的密碼已被更改",
- "UserPolicyUpdatedWithName": "使用條款已更新為 {0}",
+ "UserPolicyUpdatedWithName": "{0} 嘅用戶權限已經更新咗",
"UserStartedPlayingItemWithValues": "{0} 在 {2} 上播放 {1}",
"UserStoppedPlayingItemWithValues": "{0} 停止在 {2} 上播放 {1}",
"ValueHasBeenAddedToLibrary": "{0} 已被加入至你的媒體庫",
@@ -107,7 +106,7 @@
"TaskCleanLogsDescription": "刪除超過{0}天的紀錄檔。",
"TaskCleanLogs": "清理紀錄檔資料夾",
"TaskRefreshLibrary": "掃描媒體庫",
- "TaskRefreshChapterImagesDescription": "為帶有章節的影片建立縮圖。",
+ "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整縮圖。",
"TaskRefreshChapterImages": "提取章節圖像",
"TaskCleanCacheDescription": "刪除系統不再需要的緩存文件。",
"TaskCleanCache": "清理緩存資料夾",
@@ -126,7 +125,7 @@
"External": "外部",
"HearingImpaired": "聽力障礙",
"TaskRefreshTrickplayImages": "建立 Trickplay 圖像",
- "TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。",
+ "TaskRefreshTrickplayImagesDescription": "幫已啟用功能嘅媒體庫影片整快轉預覽圖。",
"TaskExtractMediaSegments": "掃描媒體分段資訊",
"TaskExtractMediaSegmentsDescription": "從允許MediaSegment 功能的插件中獲取媒體片段。",
"TaskDownloadMissingLyrics": "下載欠缺歌詞",
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index b4c65ad85..bc80c2b40 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -38,6 +38,7 @@ namespace Emby.Server.Implementations.Localization
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
+ private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
private List<CultureDto> _cultures = [];
private FrozenDictionary<string, string> _iso6392BtoT = null!;
@@ -161,6 +162,7 @@ namespace Emby.Server.Implementations.Localization
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
}
+ _cultureCache.Clear();
_cultures = list;
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
@@ -169,20 +171,31 @@ namespace Emby.Server.Implementations.Localization
/// <inheritdoc />
public CultureDto? FindLanguageInfo(string language)
{
- // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
- for (var i = 0; i < _cultures.Count; i++)
+ if (string.IsNullOrEmpty(language))
{
- var culture = _cultures[i];
- if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
- || language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
- || culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase)
- || language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
- {
- return culture;
- }
+ return null;
}
- return default;
+ return _cultureCache.GetOrAdd(
+ language,
+ static (lang, cultures) =>
+ {
+ // TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
+ for (var i = 0; i < cultures.Count; i++)
+ {
+ var culture = cultures[i];
+ if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
+ || lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
+ || culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
+ || lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
+ {
+ return culture;
+ }
+ }
+
+ return null;
+ },
+ _cultures);
}
/// <inheritdoc />
@@ -311,15 +324,19 @@ namespace Emby.Server.Implementations.Localization
else
{
// Fall back to server default language for ratings check
- // If it has no ratings, use the US ratings
- var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
+ var ratingsDictionary = GetParentalRatingsDictionary();
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
{
return value;
}
}
- // If we don't find anything, check all ratings systems
+ // If we don't find anything, check all ratings systems, starting with US
+ if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
+ {
+ return usValue;
+ }
+
foreach (var dictionary in _allParentalRatings.Values)
{
if (dictionary.TryGetValue(rating, out var value))
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/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index cf2ca047c..8e14f5bdf 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -793,6 +793,16 @@ namespace Emby.Server.Implementations.Session
PlaySessionId = info.PlaySessionId
};
+ if (info.Item is not null)
+ {
+ _logger.LogInformation(
+ "User {0} started playback of '{1}' ({2} {3})",
+ session.UserName,
+ info.Item.Name,
+ session.Client,
+ session.ApplicationVersion);
+ }
+
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
// Nothing to save here
@@ -1060,11 +1070,12 @@ namespace Emby.Server.Implementations.Session
var msString = info.PositionTicks.HasValue ? (info.PositionTicks.Value / 10000).ToString(CultureInfo.InvariantCulture) : "unknown";
_logger.LogInformation(
- "Playback stopped reported by app {0} {1} playing {2}. Stopped at {3} ms",
- session.Client,
- session.ApplicationVersion,
+ "User {0} stopped playback of '{1}' at {2}ms ({3} {4})",
+ session.UserName,
info.Item.Name,
- msString);
+ msString,
+ session.Client,
+ session.ApplicationVersion);
}
if (info.NowPlayingQueue is not null)
@@ -1175,7 +1186,8 @@ namespace Emby.Server.Implementations.Session
return session;
}
- private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
+ /// <inheritdoc />
+ public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
{
return new SessionInfoDto
{
diff --git a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs
index 789af01cc..c0e453d63 100644
--- a/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs
+++ b/Emby.Server.Implementations/Sorting/OfficialRatingComparer.cs
@@ -41,8 +41,8 @@ public class OfficialRatingComparer : IBaseItemComparer
ArgumentNullException.ThrowIfNull(y);
var zeroRating = new ParentalRatingScore(0, 0);
- var ratingX = string.IsNullOrEmpty(x.OfficialRating) ? zeroRating : _localizationManager.GetRatingScore(x.OfficialRating) ?? zeroRating;
- var ratingY = string.IsNullOrEmpty(y.OfficialRating) ? zeroRating : _localizationManager.GetRatingScore(y.OfficialRating) ?? zeroRating;
+ var ratingX = string.IsNullOrEmpty(x.OfficialRating) ? zeroRating : _localizationManager.GetRatingScore(x.OfficialRating, x.GetPreferredMetadataCountryCode()) ?? zeroRating;
+ var ratingY = string.IsNullOrEmpty(y.OfficialRating) ? zeroRating : _localizationManager.GetRatingScore(y.OfficialRating, y.GetPreferredMetadataCountryCode()) ?? zeroRating;
var scoreCompare = ratingX.Score.CompareTo(ratingY.Score);
if (scoreCompare is 0)
{
diff --git a/Emby.Server.Implementations/Sorting/StudioComparer.cs b/Emby.Server.Implementations/Sorting/StudioComparer.cs
index 0edffb783..6d041cf11 100644
--- a/Emby.Server.Implementations/Sorting/StudioComparer.cs
+++ b/Emby.Server.Implementations/Sorting/StudioComparer.cs
@@ -1,11 +1,11 @@
#pragma warning disable CS1591
using System;
+using System.Globalization;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Sorting;
-using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Sorting
{
@@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.Sorting
ArgumentNullException.ThrowIfNull(x);
ArgumentNullException.ThrowIfNull(y);
- return AlphanumericComparator.CompareValues(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault());
+ return CultureInfo.InvariantCulture.CompareInfo.Compare(x.Studios.FirstOrDefault(), y.Studios.FirstOrDefault(), CompareOptions.NumericOrdering);
}
}
}
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index 5ff400160..67b77a112 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -156,6 +156,11 @@ namespace Emby.Server.Implementations.Updates
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
return Array.Empty<PackageInfo>();
}
+ catch (NotSupportedException ex)
+ {
+ _logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
+ return Array.Empty<PackageInfo>();
+ }
catch (HttpRequestException ex)
{
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
@@ -557,7 +562,7 @@ namespace Emby.Server.Implementations.Updates
}
stream.Position = 0;
- ZipFile.ExtractToDirectory(stream, targetDir, true);
+ await ZipFile.ExtractToDirectoryAsync(stream, targetDir, true, cancellationToken);
// Ensure we create one or populate existing ones with missing data.
await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false);
diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs
index 642790f94..99b0fde06 100644
--- a/Jellyfin.Api/Controllers/ArtistsController.cs
+++ b/Jellyfin.Api/Controllers/ArtistsController.cs
@@ -187,39 +187,7 @@ public class ArtistsController : BaseJellyfinApiController
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
- foreach (var filter in filters)
- {
- switch (filter)
- {
- case ItemFilter.Dislikes:
- query.IsLiked = false;
- break;
- case ItemFilter.IsFavorite:
- query.IsFavorite = true;
- break;
- case ItemFilter.IsFavoriteOrLikes:
- query.IsFavoriteOrLiked = true;
- break;
- case ItemFilter.IsFolder:
- query.IsFolder = true;
- break;
- case ItemFilter.IsNotFolder:
- query.IsFolder = false;
- break;
- case ItemFilter.IsPlayed:
- query.IsPlayed = true;
- break;
- case ItemFilter.IsResumable:
- query.IsResumable = true;
- break;
- case ItemFilter.IsUnplayed:
- query.IsPlayed = false;
- break;
- case ItemFilter.Likes:
- query.IsLiked = true;
- break;
- }
- }
+ query.ApplyFilters(filters);
var result = _libraryManager.GetArtists(query);
@@ -390,39 +358,7 @@ public class ArtistsController : BaseJellyfinApiController
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
}
- foreach (var filter in filters)
- {
- switch (filter)
- {
- case ItemFilter.Dislikes:
- query.IsLiked = false;
- break;
- case ItemFilter.IsFavorite:
- query.IsFavorite = true;
- break;
- case ItemFilter.IsFavoriteOrLikes:
- query.IsFavoriteOrLiked = true;
- break;
- case ItemFilter.IsFolder:
- query.IsFolder = true;
- break;
- case ItemFilter.IsNotFolder:
- query.IsFolder = false;
- break;
- case ItemFilter.IsPlayed:
- query.IsPlayed = true;
- break;
- case ItemFilter.IsResumable:
- query.IsResumable = true;
- break;
- case ItemFilter.IsUnplayed:
- query.IsPlayed = false;
- break;
- case ItemFilter.Likes:
- query.IsLiked = true;
- break;
- }
- }
+ query.ApplyFilters(filters);
var result = _libraryManager.GetAlbumArtists(query);
diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs
index e334e1264..4be79ff5a 100644
--- a/Jellyfin.Api/Controllers/AudioController.cs
+++ b/Jellyfin.Api/Controllers/AudioController.cs
@@ -50,7 +50,6 @@ public class AudioController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
@@ -107,7 +106,6 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? audioBitRate,
@@ -159,7 +157,6 @@ public class AudioController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
@@ -217,7 +214,6 @@ public class AudioController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
@@ -274,7 +270,6 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? audioBitRate,
@@ -326,7 +321,6 @@ public class AudioController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs
index 880b3a82d..0d85b3a0d 100644
--- a/Jellyfin.Api/Controllers/ChannelsController.cs
+++ b/Jellyfin.Api/Controllers/ChannelsController.cs
@@ -136,45 +136,13 @@ public class ChannelsController : BaseJellyfinApiController
{
Limit = limit,
StartIndex = startIndex,
- ChannelIds = new[] { channelId },
+ ChannelIds = [channelId],
ParentId = folderId ?? Guid.Empty,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
DtoOptions = new DtoOptions { Fields = fields }
};
- foreach (var filter in filters)
- {
- switch (filter)
- {
- case ItemFilter.IsFolder:
- query.IsFolder = true;
- break;
- case ItemFilter.IsNotFolder:
- query.IsFolder = false;
- break;
- case ItemFilter.IsUnplayed:
- query.IsPlayed = false;
- break;
- case ItemFilter.IsPlayed:
- query.IsPlayed = true;
- break;
- case ItemFilter.IsFavorite:
- query.IsFavorite = true;
- break;
- case ItemFilter.IsResumable:
- query.IsResumable = true;
- break;
- case ItemFilter.Likes:
- query.IsLiked = true;
- break;
- case ItemFilter.Dislikes:
- query.IsLiked = false;
- break;
- case ItemFilter.IsFavoriteOrLikes:
- query.IsFavoriteOrLiked = true;
- break;
- }
- }
+ query.ApplyFilters(filters);
return await _channelManager.GetChannelItems(query, CancellationToken.None).ConfigureAwait(false);
}
@@ -215,39 +183,7 @@ public class ChannelsController : BaseJellyfinApiController
DtoOptions = new DtoOptions { Fields = fields }
};
- foreach (var filter in filters)
- {
- switch (filter)
- {
- case ItemFilter.IsFolder:
- query.IsFolder = true;
- break;
- case ItemFilter.IsNotFolder:
- query.IsFolder = false;
- break;
- case ItemFilter.IsUnplayed:
- query.IsPlayed = false;
- break;
- case ItemFilter.IsPlayed:
- query.IsPlayed = true;
- break;
- case ItemFilter.IsFavorite:
- query.IsFavorite = true;
- break;
- case ItemFilter.IsResumable:
- query.IsResumable = true;
- break;
- case ItemFilter.Likes:
- query.IsLiked = true;
- break;
- case ItemFilter.Dislikes:
- query.IsLiked = false;
- break;
- case ItemFilter.IsFavoriteOrLikes:
- query.IsFavoriteOrLiked = true;
- break;
- }
- }
+ query.ApplyFilters(filters);
return await _channelManager.GetLatestChannelItems(query, CancellationToken.None).ConfigureAwait(false);
}
diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs
index 8dcaebf6d..9e03fbeb0 100644
--- a/Jellyfin.Api/Controllers/ConfigurationController.cs
+++ b/Jellyfin.Api/Controllers/ConfigurationController.cs
@@ -3,8 +3,6 @@ using System.ComponentModel.DataAnnotations;
using System.Net.Mime;
using System.Text.Json;
using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
-using Jellyfin.Api.Models.ConfigurationDtos;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Configuration;
@@ -143,22 +141,4 @@ public class ConfigurationController : BaseJellyfinApiController
return NoContent();
}
-
- /// <summary>
- /// Updates the path to the media encoder.
- /// </summary>
- /// <param name="mediaEncoderPath">Media encoder path form body.</param>
- /// <response code="204">Media encoder path updated.</response>
- /// <returns>Status.</returns>
- [Obsolete("This endpoint is obsolete.")]
- [ApiExplorerSettings(IgnoreApi = true)]
- [HttpPost("MediaEncoder/Path")]
- [Authorize(Policy = Policies.FirstTimeSetupOrElevated)]
- [ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult UpdateMediaEncoderPath([FromBody, Required] MediaEncoderPathDto mediaEncoderPath)
- {
- // API ENDPOINT DISABLED (NOOP) FOR SECURITY PURPOSES
- // _mediaEncoder.UpdateEncoderPath(mediaEncoderPath.Path, mediaEncoderPath.PathType);
- return NoContent();
- }
}
diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
index 585318d24..ef54e9db5 100644
--- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
+++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
@@ -191,9 +191,17 @@ public class DisplayPreferencesController : BaseJellyfinApiController
foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
{
- if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out _))
+ var viewType = displayPreferences.CustomPrefs[key];
+
+ if (string.IsNullOrEmpty(viewType))
+ {
+ displayPreferences.CustomPrefs.Remove(key);
+ continue;
+ }
+
+ if (!Enum.TryParse<ViewType>(viewType, true, out _))
{
- _logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]);
+ _logger.LogError("Invalid ViewType: {LandingScreenOption}", viewType);
displayPreferences.CustomPrefs.Remove(key);
}
}
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index fe6f855b5..acd5dd64e 100644
--- a/Jellyfin.Api/Controllers/DynamicHlsController.cs
+++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs
@@ -122,7 +122,6 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
@@ -182,7 +181,6 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? audioBitRate,
@@ -238,7 +236,6 @@ public class DynamicHlsController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
@@ -364,7 +361,6 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
@@ -425,7 +421,6 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? audioBitRate,
@@ -481,7 +476,6 @@ public class DynamicHlsController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
@@ -543,7 +537,6 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
@@ -601,7 +594,6 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? maxStreamingBitrate,
@@ -654,7 +646,6 @@ public class DynamicHlsController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate ?? maxStreamingBitrate,
@@ -713,7 +704,6 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
@@ -771,7 +761,6 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? audioBitRate,
@@ -826,7 +815,6 @@ public class DynamicHlsController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
@@ -887,7 +875,6 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
@@ -943,7 +930,6 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? maxStreamingBitrate,
@@ -996,7 +982,6 @@ public class DynamicHlsController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate ?? maxStreamingBitrate,
@@ -1060,7 +1045,6 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
@@ -1124,7 +1108,6 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? audioBitRate,
@@ -1181,7 +1164,6 @@ public class DynamicHlsController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
@@ -1247,7 +1229,6 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="maxStreamingBitrate">Optional. The maximum streaming bitrate.</param>
@@ -1309,7 +1290,6 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? maxStreamingBitrate,
@@ -1364,7 +1344,6 @@ public class DynamicHlsController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate ?? maxStreamingBitrate,
@@ -1421,10 +1400,20 @@ public class DynamicHlsController : BaseJellyfinApiController
cancellationTokenSource.Token)
.ConfigureAwait(false);
var mediaSourceId = state.BaseRequest.MediaSourceId;
+ double fps = state.TargetFramerate ?? 0.0f;
+ int segmentLength = state.SegmentLength * 1000;
+
+ // If video is transcoded and framerate is fractional (i.e. 23.976), we need to slightly adjust segment length
+ if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && Math.Abs(fps - Math.Floor(fps + 0.001f)) > 0.001)
+ {
+ double nearestIntFramerate = Math.Ceiling(fps);
+ segmentLength = (int)Math.Ceiling(segmentLength * (nearestIntFramerate / fps));
+ }
+
var request = new CreateMainPlaylistRequest(
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
state.MediaPath,
- state.SegmentLength * 1000,
+ segmentLength,
state.RunTimeTicks ?? 0,
state.Request.SegmentContainer ?? string.Empty,
"hls1/main/",
@@ -1586,16 +1575,6 @@ public class DynamicHlsController : BaseJellyfinApiController
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
- if (state.BaseRequest.BreakOnNonKeyFrames)
- {
- // FIXME: this is actually a workaround, as ideally it really should be the client which decides whether non-keyframe
- // breakpoints are supported; but current implementation always uses "ffmpeg input seeking" which is liable
- // to produce a missing part of video stream before first keyframe is encountered, which may lead to
- // awkward cases like a few starting HLS segments having no video whatsoever, which breaks hls.js
- _logger.LogInformation("Current HLS implementation doesn't support non-keyframe breaks but one is requested, ignoring that request");
- state.BaseRequest.BreakOnNonKeyFrames = false;
- }
-
var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
@@ -1746,11 +1725,6 @@ public class DynamicHlsController : BaseJellyfinApiController
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs;
- if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
- {
- return copyArgs + " -copypriorss:a:0 0";
- }
-
return copyArgs;
}
@@ -1839,8 +1813,9 @@ public class DynamicHlsController : BaseJellyfinApiController
{
if (isActualOutputVideoCodecHevc)
{
- // Prefer dvh1 to dvhe
- args += " -tag:v:0 dvh1 -strict -2";
+ // Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
+ var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
+ args += $" -tag:v:0 {codecTag} -strict -2";
}
else if (isActualOutputVideoCodecAv1)
{
diff --git a/Jellyfin.Api/Controllers/EnvironmentController.cs b/Jellyfin.Api/Controllers/EnvironmentController.cs
index 284a97621..794ca9693 100644
--- a/Jellyfin.Api/Controllers/EnvironmentController.cs
+++ b/Jellyfin.Api/Controllers/EnvironmentController.cs
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
-using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.EnvironmentDtos;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Extensions;
@@ -129,20 +128,6 @@ public class EnvironmentController : BaseJellyfinApiController
}
/// <summary>
- /// Gets network paths.
- /// </summary>
- /// <response code="200">Empty array returned.</response>
- /// <returns>List of entries.</returns>
- [Obsolete("This endpoint is obsolete.")]
- [HttpGet("NetworkShares")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<IEnumerable<FileSystemEntryInfo>> GetNetworkShares()
- {
- _logger.LogWarning("Obsolete endpoint accessed: /Environment/NetworkShares");
- return Array.Empty<FileSystemEntryInfo>();
- }
-
- /// <summary>
/// Gets available drives from the server's file system.
/// </summary>
/// <response code="200">List of entries returned.</response>
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index e1d9b6bba..4faec060d 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -180,11 +180,14 @@ public class ItemUpdateController : BaseJellyfinApiController
info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
info.ContentType = configuredContentType;
- if (inheritedContentType is null || inheritedContentType == CollectionType.tvshows)
+ if (inheritedContentType is null
+ || inheritedContentType == CollectionType.tvshows
+ || inheritedContentType == CollectionType.movies)
{
info.ContentTypeOptions = info.ContentTypeOptions
.Where(i => string.IsNullOrWhiteSpace(i.Value)
- || string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(i.Value, "Movies", StringComparison.OrdinalIgnoreCase))
.ToArray();
}
}
@@ -246,7 +249,7 @@ public class ItemUpdateController : BaseJellyfinApiController
item.IndexNumber = request.IndexNumber;
item.ParentIndexNumber = request.ParentIndexNumber;
item.Overview = request.Overview;
- item.Genres = request.Genres;
+ item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (item is Episode episode)
{
@@ -267,7 +270,7 @@ public class ItemUpdateController : BaseJellyfinApiController
if (request.Studios is not null)
{
- item.Studios = Array.ConvertAll(request.Studios, x => x.Name);
+ item.Studios = Array.ConvertAll(request.Studios, x => x.Name).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
if (request.DateCreated.HasValue)
@@ -284,7 +287,7 @@ public class ItemUpdateController : BaseJellyfinApiController
item.CustomRating = request.CustomRating;
var currentTags = item.Tags;
- var newTags = request.Tags;
+ var newTags = request.Tags.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var removedTags = currentTags.Except(newTags).ToList();
var addedTags = newTags.Except(currentTags).ToList();
item.Tags = newTags;
@@ -370,7 +373,7 @@ public class ItemUpdateController : BaseJellyfinApiController
if (request.ProductionLocations is not null)
{
- item.ProductionLocations = request.ProductionLocations;
+ item.ProductionLocations = request.ProductionLocations.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
item.PreferredMetadataCountryCode = request.PreferredMetadataCountryCode;
@@ -418,7 +421,7 @@ public class ItemUpdateController : BaseJellyfinApiController
{
if (item is IHasAlbumArtist hasAlbumArtists)
{
- hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name);
+ hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}
@@ -426,7 +429,7 @@ public class ItemUpdateController : BaseJellyfinApiController
{
if (item is IHasArtist hasArtists)
{
- hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name);
+ hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 9674ecd09..091a0c8c7 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -386,39 +386,7 @@ public class ItemsController : BaseJellyfinApiController
query.CollapseBoxSetItems = false;
}
- foreach (var filter in filters)
- {
- switch (filter)
- {
- case ItemFilter.Dislikes:
- query.IsLiked = false;
- break;
- case ItemFilter.IsFavorite:
- query.IsFavorite = true;
- break;
- case ItemFilter.IsFavoriteOrLikes:
- query.IsFavoriteOrLiked = true;
- break;
- case ItemFilter.IsFolder:
- query.IsFolder = true;
- break;
- case ItemFilter.IsNotFolder:
- query.IsFolder = false;
- break;
- case ItemFilter.IsPlayed:
- query.IsPlayed = true;
- break;
- case ItemFilter.IsResumable:
- query.IsResumable = true;
- break;
- case ItemFilter.IsUnplayed:
- query.IsPlayed = false;
- break;
- case ItemFilter.Likes:
- query.IsLiked = true;
- break;
- }
- }
+ query.ApplyFilters(filters);
// Filter by Series Status
if (seriesStatus.Length != 0)
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index 4e898c00e..558e1c6c8 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -23,6 +23,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Activity;
@@ -700,7 +701,18 @@ public class LibraryController : BaseJellyfinApiController
// Quotes are valid in linux. They'll possibly cause issues here.
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
- return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
+ var filePath = item.Path;
+ if (item.IsFileProtocol)
+ {
+ // PhysicalFile does not work well with symlinks at the moment.
+ var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
+ if (resolved is not null && resolved.Exists)
+ {
+ filePath = resolved.FullName;
+ }
+ }
+
+ return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index 2a885662b..117811429 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController
return NotFound();
}
+ LibraryOptions options = item.GetLibraryOptions();
+ foreach (var mediaPath in request.LibraryOptions!.PathInfos)
+ {
+ if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
+ {
+ continue;
+ }
+
+ _libraryManager.CreateShortcut(item.Path, mediaPath);
+ }
+
item.UpdateLibraryOptions(request.LibraryOptions);
return NoContent();
}
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.Api/Controllers/QuickConnectController.cs b/Jellyfin.Api/Controllers/QuickConnectController.cs
index 14f5265aa..bdb2a4d20 100644
--- a/Jellyfin.Api/Controllers/QuickConnectController.cs
+++ b/Jellyfin.Api/Controllers/QuickConnectController.cs
@@ -52,6 +52,7 @@ public class QuickConnectController : BaseJellyfinApiController
/// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
[HttpPost("Initiate")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect()
{
try
@@ -66,16 +67,6 @@ public class QuickConnectController : BaseJellyfinApiController
}
/// <summary>
- /// Old version of <see cref="InitiateQuickConnect" /> using a GET method.
- /// Still available to avoid breaking compatibility.
- /// </summary>
- /// <returns>The result of <see cref="InitiateQuickConnect" />.</returns>
- [Obsolete("Use POST request instead")]
- [HttpGet("Initiate")]
- [ApiExplorerSettings(IgnoreApi = true)]
- public Task<ActionResult<QuickConnectResult>> InitiateQuickConnectLegacy() => InitiateQuickConnect();
-
- /// <summary>
/// Attempts to retrieve authentication information.
/// </summary>
/// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs
index 2cf66144c..c9f8b3676 100644
--- a/Jellyfin.Api/Controllers/TrickplayController.cs
+++ b/Jellyfin.Api/Controllers/TrickplayController.cs
@@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
[FromRoute, Required] int index,
[FromQuery] Guid? mediaSourceId)
{
- var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
+ var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
if (item is null)
{
return NotFound();
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index 2817e3cbc..c86c9b8f6 100644
--- a/Jellyfin.Api/Controllers/TvShowsController.cs
+++ b/Jellyfin.Api/Controllers/TvShowsController.cs
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
-using Jellyfin.Api.Attributes;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
@@ -69,7 +68,6 @@ public class TvShowsController : BaseJellyfinApiController
/// <param name="enableUserData">Optional. Include user data.</param>
/// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param>
/// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param>
- /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param>
/// <param name="enableResumable">Whether to include resumable episodes in next up results.</param>
/// <param name="enableRewatching">Whether to include watched episodes in next up results.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns>
@@ -88,7 +86,6 @@ public class TvShowsController : BaseJellyfinApiController
[FromQuery] bool? enableUserData,
[FromQuery] DateTime? nextUpDateCutoff,
[FromQuery] bool enableTotalRecordCount = true,
- [FromQuery][ParameterObsolete] bool disableFirstEpisode = false,
[FromQuery] bool enableResumable = true,
[FromQuery] bool enableRewatching = false)
{
diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs
index fd6334703..b1a91ae70 100644
--- a/Jellyfin.Api/Controllers/UniversalAudioController.cs
+++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs
@@ -83,7 +83,6 @@ public class UniversalAudioController : BaseJellyfinApiController
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param>
/// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param>
/// <response code="200">Audio stream returned.</response>
/// <response code="302">Redirected to remote audio stream.</response>
@@ -114,7 +113,6 @@ public class UniversalAudioController : BaseJellyfinApiController
[FromQuery] int? maxAudioBitDepth,
[FromQuery] bool? enableRemoteMedia,
[FromQuery] bool enableAudioVbrEncoding = true,
- [FromQuery] bool breakOnNonKeyFrames = false,
[FromQuery] bool enableRedirection = true)
{
userId = RequestHelpers.GetUserId(User, userId);
@@ -127,7 +125,7 @@ public class UniversalAudioController : BaseJellyfinApiController
return NotFound();
}
- var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
+ var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
_logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile);
@@ -208,7 +206,6 @@ public class UniversalAudioController : BaseJellyfinApiController
EnableAutoStreamCopy = true,
AllowAudioStreamCopy = true,
AllowVideoStreamCopy = true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames,
AudioSampleRate = maxAudioSampleRate,
MaxAudioChannels = maxAudioChannels,
MaxAudioBitDepth = maxAudioBitDepth,
@@ -242,7 +239,6 @@ public class UniversalAudioController : BaseJellyfinApiController
EnableAutoStreamCopy = true,
AllowAudioStreamCopy = true,
AllowVideoStreamCopy = true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames,
AudioSampleRate = maxAudioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = isStatic ? null : (audioBitRate ?? maxStreamingBitrate),
@@ -263,7 +259,6 @@ public class UniversalAudioController : BaseJellyfinApiController
string? transcodingContainer,
string? audioCodec,
MediaStreamProtocol? transcodingProtocol,
- bool? breakOnNonKeyFrames,
int? transcodingAudioChannels,
int? maxAudioSampleRate,
int? maxAudioBitDepth,
@@ -298,7 +293,6 @@ public class UniversalAudioController : BaseJellyfinApiController
Container = transcodingContainer ?? "mp3",
AudioCodec = audioCodec ?? "mp3",
Protocol = transcodingProtocol ?? MediaStreamProtocol.http,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
MaxAudioChannels = transcodingAudioChannels?.ToString(CultureInfo.InvariantCulture)
}
};
diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs
index d0ced277a..536b95dbb 100644
--- a/Jellyfin.Api/Controllers/UserController.cs
+++ b/Jellyfin.Api/Controllers/UserController.cs
@@ -338,29 +338,6 @@ public class UserController : BaseJellyfinApiController
=> UpdateUserPassword(userId, request);
/// <summary>
- /// Updates a user's easy password.
- /// </summary>
- /// <param name="userId">The user id.</param>
- /// <param name="request">The <see cref="UpdateUserEasyPassword"/> request.</param>
- /// <response code="204">Password successfully reset.</response>
- /// <response code="403">User is not allowed to update the password.</response>
- /// <response code="404">User not found.</response>
- /// <returns>A <see cref="NoContentResult"/> indicating success or a <see cref="ForbidResult"/> or a <see cref="NotFoundResult"/> on failure.</returns>
- [HttpPost("{userId}/EasyPassword")]
- [Obsolete("Use Quick Connect instead")]
- [ApiExplorerSettings(IgnoreApi = true)]
- [Authorize]
- [ProducesResponseType(StatusCodes.Status204NoContent)]
- [ProducesResponseType(StatusCodes.Status403Forbidden)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public ActionResult UpdateUserEasyPassword(
- [FromRoute, Required] Guid userId,
- [FromBody, Required] UpdateUserEasyPassword request)
- {
- return Forbid();
- }
-
- /// <summary>
/// Updates a user.
/// </summary>
/// <param name="userId">The user id.</param>
diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs
index e7c6f23ce..ccf8e9063 100644
--- a/Jellyfin.Api/Controllers/VideosController.cs
+++ b/Jellyfin.Api/Controllers/VideosController.cs
@@ -270,7 +270,6 @@ public class VideosController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
@@ -329,7 +328,6 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? audioBitRate,
@@ -386,7 +384,6 @@ public class VideosController : BaseJellyfinApiController
EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
- BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
AudioSampleRate = audioSampleRate,
MaxAudioChannels = maxAudioChannels,
AudioBitRate = audioBitRate,
@@ -511,7 +508,6 @@ public class VideosController : BaseJellyfinApiController
/// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
/// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
/// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
- /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
@@ -570,7 +566,6 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
- [FromQuery] bool? breakOnNonKeyFrames,
[FromQuery] int? audioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] int? audioBitRate,
@@ -624,7 +619,6 @@ public class VideosController : BaseJellyfinApiController
enableAutoStreamCopy,
allowVideoStreamCopy,
allowAudioStreamCopy,
- breakOnNonKeyFrames,
audioSampleRate,
maxAudioBitDepth,
audioBitRate,
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
index a38ad379c..44e1c6d5a 100644
--- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
+++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
@@ -154,7 +154,7 @@ public class DynamicHlsHelper
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
{
- var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
+ var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
newQuery["AudioCodec"] = state.OutputAudioCodec;
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
}
@@ -173,10 +173,21 @@ public class DynamicHlsHelper
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
}
- // Main stream
- var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
+ // Video rotation metadata is only supported in fMP4 remuxing
+ if (state.VideoStream is not null
+ && state.VideoRequest is not null
+ && (state.VideoStream?.Rotation ?? 0) != 0
+ && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
+ && !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
+ {
+ queryString += "&AllowVideoStreamCopy=false";
+ }
- playlistUrl += queryString;
+ // Main stream
+ var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
+ var playlistUrl = baseUrl + queryString;
+ var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
var subtitleStreams = state.MediaSource
.MediaStreams
@@ -198,37 +209,36 @@ public class DynamicHlsHelper
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
}
- // Video rotation metadata is only supported in fMP4 remuxing
- if (state.VideoStream is not null
- && state.VideoRequest is not null
- && (state.VideoStream?.Rotation ?? 0) != 0
- && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
- && !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
- && !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
- {
- playlistUrl += "&AllowVideoStreamCopy=false";
- }
-
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
if (state.VideoStream is not null && state.VideoRequest is not null)
{
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
- // Provide SDR HEVC entrance for backward compatibility.
- if (encodingOptions.AllowHevcEncoding
- && !encodingOptions.AllowAv1Encoding
- && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
- && state.VideoStream.VideoRange == VideoRange.HDR
- && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+ // Provide AV1 and HEVC SDR entrances for backward compatibility.
+ foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
{
- var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
- if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
+ var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
+ && string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
+ var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
+ && string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
+ var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
+
+ if (isEncodingAllowed
+ && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && state.VideoStream.VideoRange == VideoRange.HDR)
{
- // Force HEVC Main Profile and disable video stream copy.
- state.OutputVideoCodec = "hevc";
- var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
- sdrVideoUrl += "&AllowVideoStreamCopy=false";
+ // Force AV1 and HEVC Main Profile and disable video stream copy.
+ state.OutputVideoCodec = sdrVideoCodec;
+
+ var sdrPlaylistQuery = playlistQuery;
+ sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
+ sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
+ sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
+
+ var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
@@ -238,12 +248,30 @@ public class DynamicHlsHelper
}
}
+ // Provide H.264 SDR entrance for backward compatibility.
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && state.VideoStream.VideoRange == VideoRange.HDR)
+ {
+ // Force H.264 and disable video stream copy.
+ state.OutputVideoCodec = "h264";
+
+ var sdrPlaylistQuery = playlistQuery;
+ sdrPlaylistQuery["VideoCodec"] = "h264";
+ sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
+
+ var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
+
+ // HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
+ AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
+
+ // Restore the video codec
+ state.OutputVideoCodec = "copy";
+ }
+
// Provide Level 5.0 entrance for backward compatibility.
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
// but in fact it is capable of playing videos up to Level 6.1.
- if (encodingOptions.AllowHevcEncoding
- && !encodingOptions.AllowAv1Encoding
- && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.Level.HasValue
&& state.VideoStream.Level > 150
&& state.VideoStream.VideoRange == VideoRange.SDR
@@ -273,12 +301,15 @@ public class DynamicHlsHelper
var variation = GetBitrateVariation(totalBitrate);
var newBitrate = totalBitrate - variation;
- var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+ var variantQuery = playlistQuery;
+ variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
+ var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
variation *= 2;
newBitrate = totalBitrate - variation;
- variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+ variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
+ variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
}
@@ -723,7 +754,9 @@ public class DynamicHlsHelper
{
if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
{
- string? profile = state.GetRequestedProfiles("aac").FirstOrDefault();
+ string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
+ ? state.AudioStream?.Profile : state.GetRequestedProfiles("aac").FirstOrDefault();
+
return HlsCodecStringHelpers.GetAACString(profile);
}
@@ -757,6 +790,19 @@ public class DynamicHlsHelper
return HlsCodecStringHelpers.GetOPUSString();
}
+ if (string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase))
+ {
+ return HlsCodecStringHelpers.GetTRUEHDString();
+ }
+
+ if (string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase))
+ {
+ // lavc only support encoding DTS core profile
+ string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) ? state.AudioStream?.Profile : "DTS";
+
+ return HlsCodecStringHelpers.GetDTSString(profile);
+ }
+
return string.Empty;
}
@@ -863,23 +909,6 @@ public class DynamicHlsHelper
return variation;
}
- private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
- {
- return url.Replace(
- "videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
- "videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
- StringComparison.OrdinalIgnoreCase);
- }
-
- private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
- {
- string profileStr = codec + "-profile=";
- return url.Replace(
- profileStr + oldValue,
- profileStr + newValue,
- StringComparison.OrdinalIgnoreCase);
- }
-
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
{
var oldPlaylist = playlist.ToString();
diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
index 0efb7f45d..cf42d5f10 100644
--- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
+++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
@@ -42,6 +42,11 @@ public static class HlsCodecStringHelpers
public const string OPUS = "Opus";
/// <summary>
+ /// Codec name for TRUEHD.
+ /// </summary>
+ public const string TRUEHD = "mlpa";
+
+ /// <summary>
/// Gets a MP3 codec string.
/// </summary>
/// <returns>MP3 codec string.</returns>
@@ -59,7 +64,7 @@ public static class HlsCodecStringHelpers
{
StringBuilder result = new StringBuilder("mp4a", 9);
- if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(profile, "HE-AAC", StringComparison.OrdinalIgnoreCase))
{
result.Append(".40.5");
}
@@ -118,6 +123,46 @@ public static class HlsCodecStringHelpers
}
/// <summary>
+ /// Gets an TRUEHD codec string.
+ /// </summary>
+ /// <returns>TRUEHD codec string.</returns>
+ public static string GetTRUEHDString()
+ {
+ return TRUEHD;
+ }
+
+ /// <summary>
+ /// Gets an DTS codec string.
+ /// </summary>
+ /// <param name="profile">DTS profile.</param>
+ /// <returns>DTS codec string.</returns>
+ public static string GetDTSString(string? profile)
+ {
+ if (string.Equals(profile, "DTS", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(profile, "DTS-ES", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(profile, "DTS 96/24", StringComparison.OrdinalIgnoreCase))
+ {
+ return "dtsc";
+ }
+
+ if (string.Equals(profile, "DTS-HD HRA", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(profile, "DTS-HD MA", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(profile, "DTS-HD MA + DTS:X", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(profile, "DTS-HD MA + DTS:X IMAX", StringComparison.OrdinalIgnoreCase))
+ {
+ return "dtsh";
+ }
+
+ if (string.Equals(profile, "DTS Express", StringComparison.OrdinalIgnoreCase))
+ {
+ return "dtse";
+ }
+
+ // Default to DTS core if profile is invalid
+ return "dtsc";
+ }
+
+ /// <summary>
/// Gets a H.264 codec string.
/// </summary>
/// <param name="profile">H.264 profile.</param>
diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs
index cad8d650e..15540338b 100644
--- a/Jellyfin.Api/Helpers/HlsHelpers.cs
+++ b/Jellyfin.Api/Helpers/HlsHelpers.cs
@@ -45,15 +45,9 @@ public static class HlsHelpers
using var reader = new StreamReader(fileStream);
var count = 0;
- while (!reader.EndOfStream)
+ string? line;
+ while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
{
- var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
- if (line is null)
- {
- // Nothing currently in buffer.
- break;
- }
-
if (line.Contains("#EXTINF:", StringComparison.OrdinalIgnoreCase))
{
count++;
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index 2601fa3be..c6823fa80 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -159,6 +159,13 @@ public static class StreamingHelpers
string? containerInternal = Path.GetExtension(state.RequestedUrl);
+ if (string.IsNullOrEmpty(containerInternal)
+ && (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)
+ || (mediaSource != null && mediaSource.IsInfiniteStream)))
+ {
+ containerInternal = ".ts";
+ }
+
if (!string.IsNullOrEmpty(streamingRequest.Container))
{
containerInternal = streamingRequest.Container;
@@ -194,7 +201,7 @@ public static class StreamingHelpers
state.OutputVideoCodec = state.Request.VideoCodec;
state.OutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec);
- encodingHelper.TryStreamCopy(state);
+ encodingHelper.TryStreamCopy(state, encodingOptions);
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue)
{
@@ -261,7 +268,7 @@ public static class StreamingHelpers
Dictionary<string, string?> streamOptions = new Dictionary<string, string?>();
foreach (var param in queryString)
{
- if (char.IsLower(param.Key[0]))
+ if (param.Key.Length > 0 && char.IsLower(param.Key[0]))
{
// This was probably not parsed initially and should be a StreamOptions
// or the generated URL should correctly serialize it
diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj
index 25feaa2d7..3ccf7a746 100644
--- a/Jellyfin.Api/Jellyfin.Api.csproj
+++ b/Jellyfin.Api/Jellyfin.Api.csproj
@@ -6,7 +6,7 @@
</PropertyGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs b/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
deleted file mode 100644
index 5a48345eb..000000000
--- a/Jellyfin.Api/Models/ConfigurationDtos/MediaEncoderPathDto.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace Jellyfin.Api.Models.ConfigurationDtos;
-
-/// <summary>
-/// Media Encoder Path Dto.
-/// </summary>
-public class MediaEncoderPathDto
-{
- /// <summary>
- /// Gets or sets media encoder path.
- /// </summary>
- public string Path { get; set; } = null!;
-
- /// <summary>
- /// Gets or sets media encoder path type.
- /// </summary>
- public string PathType { get; set; } = null!;
-}
diff --git a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs
index 9c29e372c..2a1a312d5 100644
--- a/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs
+++ b/Jellyfin.Api/Models/StartupDtos/StartupRemoteAccessDto.cs
@@ -1,4 +1,3 @@
-using System;
using System.ComponentModel.DataAnnotations;
namespace Jellyfin.Api.Models.StartupDtos;
@@ -13,11 +12,4 @@ public class StartupRemoteAccessDto
/// </summary>
[Required]
public bool EnableRemoteAccess { get; set; }
-
- /// <summary>
- /// Gets or sets a value indicating whether enable automatic port mapping.
- /// </summary>
- [Required]
- [Obsolete("No longer supported")]
- public bool EnableAutomaticPortMapping { get; set; }
}
diff --git a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs b/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs
deleted file mode 100644
index f19d0b57a..000000000
--- a/Jellyfin.Api/Models/UserDtos/UpdateUserEasyPassword.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-namespace Jellyfin.Api.Models.UserDtos;
-
-/// <summary>
-/// The update user easy password request body.
-/// </summary>
-public class UpdateUserEasyPassword
-{
- /// <summary>
- /// Gets or sets the new sha1-hashed password.
- /// </summary>
- public string? NewPassword { get; set; }
-
- /// <summary>
- /// Gets or sets the new password.
- /// </summary>
- public string? NewPw { get; set; }
-
- /// <summary>
- /// Gets or sets a value indicating whether to reset the password.
- /// </summary>
- public bool ResetPassword { get; set; }
-}
diff --git a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs
index 143d82bac..db24c9746 100644
--- a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs
+++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs
@@ -7,6 +7,7 @@ using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging;
@@ -15,7 +16,7 @@ namespace Jellyfin.Api.WebSocketListeners;
/// <summary>
/// Class SessionInfoWebSocketListener.
/// </summary>
-public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState>
+public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
{
private readonly ISessionManager _sessionManager;
private bool _disposed;
@@ -52,24 +53,26 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
/// Gets the data to send.
/// </summary>
/// <returns>Task{SystemInfo}.</returns>
- protected override Task<IEnumerable<SessionInfo>> GetDataToSend()
+ protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
{
- return Task.FromResult(_sessionManager.Sessions);
+ return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
}
/// <inheritdoc />
- protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection)
+ protected override Task<IEnumerable<SessionInfoDto>> GetDataToSendForConnection(IWebSocketConnection connection)
{
+ var sessions = _sessionManager.Sessions;
+
// For non-admin users, filter the sessions to only include their own sessions
if (connection.AuthorizationInfo?.User is not null &&
!connection.AuthorizationInfo.IsApiKey &&
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
{
var userId = connection.AuthorizationInfo.User.Id;
- return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId)));
+ sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
}
- return Task.FromResult(_sessionManager.Sessions);
+ return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
}
/// <inheritdoc />
diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj
index fd852ece9..f7660f35d 100644
--- a/Jellyfin.Data/Jellyfin.Data.csproj
+++ b/Jellyfin.Data/Jellyfin.Data.csproj
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
index 70483c36c..30094a88c 100644
--- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
@@ -102,7 +102,7 @@ public class BackupService : IBackupService
}
BackupManifest? manifest;
- var manifestStream = zipArchiveEntry.Open();
+ var manifestStream = await zipArchiveEntry.OpenAsync().ConfigureAwait(false);
await using (manifestStream.ConfigureAwait(false))
{
manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
@@ -160,7 +160,7 @@ public class BackupService : IBackupService
}
HistoryRow[] historyEntries;
- var historyArchive = historyEntry.Open();
+ var historyArchive = await historyEntry.OpenAsync().ConfigureAwait(false);
await using (historyArchive.ConfigureAwait(false))
{
historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
@@ -204,7 +204,7 @@ public class BackupService : IBackupService
continue;
}
- var zipEntryStream = zipEntry.Open();
+ var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false);
await using (zipEntryStream.ConfigureAwait(false))
{
_logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
@@ -329,7 +329,7 @@ public class BackupService : IBackupService
_logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
var entities = 0;
- var zipEntryStream = zipEntry.Open();
+ var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false);
await using (zipEntryStream.ConfigureAwait(false))
{
var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
@@ -366,7 +366,7 @@ public class BackupService : IBackupService
foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
.Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
{
- zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
+ await zipArchive.CreateEntryFromFileAsync(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item)))).ConfigureAwait(false);
}
void CopyDirectory(string source, string target, string filter = "*")
@@ -380,6 +380,7 @@ public class BackupService : IBackupService
foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
{
+ // TODO: @bond make async
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
}
}
@@ -405,7 +406,7 @@ public class BackupService : IBackupService
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
}
- var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
+ var manifestStream = await zipArchive.CreateEntry(ManifestEntryName).OpenAsync().ConfigureAwait(false);
await using (manifestStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
@@ -505,7 +506,7 @@ public class BackupService : IBackupService
return null;
}
- var manifestStream = manifestEntry.Open();
+ var manifestStream = await manifestEntry.OpenAsync().ConfigureAwait(false);
await using (manifestStream.ConfigureAwait(false))
{
return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 9851d53c4..cd28c6e43 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -277,7 +277,7 @@ public sealed class BaseItemRepository
dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
- result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
+ result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
result.StartIndex = filter.StartIndex ?? 0;
return result;
}
@@ -295,9 +295,28 @@ public sealed class BaseItemRepository
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
+
+ var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
+ if (hasRandomSort)
+ {
+ var orderedIds = dbQuery.Select(e => e.Id).ToList();
+ if (orderedIds.Count == 0)
+ {
+ return Array.Empty<BaseItemDto>();
+ }
+
+ var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
+ .AsEnumerable()
+ .Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
+ .Where(dto => dto is not null)
+ .ToDictionary(i => i!.Id);
+
+ return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
+ }
+
dbQuery = ApplyNavigations(dbQuery, filter);
- return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
+ return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
}
/// <inheritdoc/>
@@ -341,7 +360,7 @@ public sealed class BaseItemRepository
mainquery = ApplyNavigations(mainquery, filter);
- return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
+ return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
}
/// <inheritdoc />
@@ -410,10 +429,25 @@ public sealed class BaseItemRepository
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
- dbQuery = dbQuery.Include(e => e.TrailerTypes)
- .Include(e => e.Provider)
- .Include(e => e.LockedFields)
- .Include(e => e.UserData);
+ if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
+ {
+ dbQuery = dbQuery.Include(e => e.TrailerTypes);
+ }
+
+ if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
+ {
+ dbQuery = dbQuery.Include(e => e.Provider);
+ }
+
+ if (filter.DtoOptions.ContainsField(ItemFields.Settings))
+ {
+ dbQuery = dbQuery.Include(e => e.LockedFields);
+ }
+
+ if (filter.DtoOptions.EnableUserData)
+ {
+ dbQuery = dbQuery.Include(e => e.UserData);
+ }
if (filter.DtoOptions.EnableImages)
{
@@ -609,7 +643,6 @@ public sealed class BaseItemRepository
var ids = tuples.Select(f => f.Item.Id).ToArray();
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
- var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
foreach (var item in tuples)
{
@@ -643,19 +676,6 @@ public sealed class BaseItemRepository
context.SaveChanges();
- foreach (var item in newItems)
- {
- // reattach old userData entries
- var userKeys = item.UserDataKey.ToArray();
- var retentionDate = (DateTime?)null;
- context.UserData
- .Where(e => e.ItemId == PlaceholderId)
- .Where(e => userKeys.Contains(e.CustomDataKey))
- .ExecuteUpdate(e => e
- .SetProperty(f => f.ItemId, item.Item.Id)
- .SetProperty(f => f.RetentionDate, retentionDate));
- }
-
var itemValueMaps = tuples
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
.ToArray();
@@ -663,14 +683,15 @@ public sealed class BaseItemRepository
.SelectMany(f => f.Values)
.Distinct()
.ToArray();
+
+ var types = allListedItemValues.Select(e => e.MagicNumber).Distinct().ToArray();
+ var values = allListedItemValues.Select(e => e.Value).Distinct().ToArray();
+ var allListedItemValuesSet = allListedItemValues.ToHashSet();
+
var existingValues = context.ItemValues
- .Select(e => new
- {
- item = e,
- Key = e.Type + "+" + e.Value
- })
- .Where(f => allListedItemValues.Select(e => $"{(int)e.MagicNumber}+{e.Value}").Contains(f.Key))
- .Select(e => e.item)
+ .Where(e => types.Contains(e.Type) && values.Contains(e.Value))
+ .AsEnumerable()
+ .Where(e => allListedItemValuesSet.Contains((e.Type, e.Value)))
.ToArray();
var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).Select(f => new ItemValue()
{
@@ -752,6 +773,43 @@ public sealed class BaseItemRepository
}
/// <inheritdoc />
+ public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(item);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ await using (transaction.ConfigureAwait(false))
+ {
+ var userKeys = item.GetUserDataKeys().ToArray();
+ var retentionDate = (DateTime?)null;
+
+ await dbContext.UserData
+ .Where(e => e.ItemId == PlaceholderId)
+ .Where(e => userKeys.Contains(e.CustomDataKey))
+ .ExecuteUpdateAsync(
+ e => e
+ .SetProperty(f => f.ItemId, item.Id)
+ .SetProperty(f => f.RetentionDate, retentionDate),
+ cancellationToken).ConfigureAwait(false);
+
+ // Rehydrate the cached userdata
+ item.UserData = await dbContext.UserData
+ .AsNoTracking()
+ .Where(e => e.ItemId == item.Id)
+ .ToArrayAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ /// <inheritdoc />
public BaseItemDto? RetrieveItem(Guid id)
{
if (id.IsEmpty())
@@ -858,7 +916,7 @@ public sealed class BaseItemRepository
}
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
- dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
+ dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
dto.Studios = entity.Studios?.Split('|') ?? [];
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
@@ -993,7 +1051,7 @@ public sealed class BaseItemRepository
entity.TotalBitrate = dto.TotalBitrate;
entity.ExternalId = dto.ExternalId;
entity.Size = dto.Size;
- entity.Genres = string.Join('|', dto.Genres);
+ entity.Genres = string.Join('|', dto.Genres.Distinct(StringComparer.OrdinalIgnoreCase));
entity.DateCreated = dto.DateCreated == DateTime.MinValue ? null : dto.DateCreated;
entity.DateModified = dto.DateModified == DateTime.MinValue ? null : dto.DateModified;
entity.ChannelId = dto.ChannelId;
@@ -1020,9 +1078,9 @@ public sealed class BaseItemRepository
}
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
- entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null;
- entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
- entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
+ entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p)).Distinct(StringComparer.OrdinalIgnoreCase)) : null;
+ entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios.Distinct(StringComparer.OrdinalIgnoreCase)) : null;
+ entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags.Distinct(StringComparer.OrdinalIgnoreCase)) : null;
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
.Select(e => new BaseItemMetadataField()
{
@@ -1065,12 +1123,12 @@ public sealed class BaseItemRepository
if (dto is IHasArtist hasArtists)
{
- entity.Artists = hasArtists.Artists is not null ? string.Join('|', hasArtists.Artists) : null;
+ entity.Artists = hasArtists.Artists is not null ? string.Join('|', hasArtists.Artists.Distinct(StringComparer.OrdinalIgnoreCase)) : null;
}
if (dto is IHasAlbumArtist hasAlbumArtists)
{
- entity.AlbumArtists = hasAlbumArtists.AlbumArtists is not null ? string.Join('|', hasAlbumArtists.AlbumArtists) : null;
+ entity.AlbumArtists = hasAlbumArtists.AlbumArtists is not null ? string.Join('|', hasAlbumArtists.AlbumArtists.Distinct(StringComparer.OrdinalIgnoreCase)) : null;
}
if (dto is LiveTvProgram program)
@@ -1144,7 +1202,7 @@ public sealed class BaseItemRepository
return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null;
}
- private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
+ private BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
{
ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity));
if (_serverConfigurationManager?.Configuration is null)
@@ -1167,11 +1225,19 @@ public sealed class BaseItemRepository
/// <param name="logger">Logger.</param>
/// <param name="appHost">The application server Host.</param>
/// <param name="skipDeserialization">If only mapping should be processed.</param>
- /// <returns>A mapped BaseItem.</returns>
- /// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception>
- public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
+ /// <returns>A mapped BaseItem, or null if the item type is unknown.</returns>
+ public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
{
- var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
+ var type = GetType(baseItemEntity.Type);
+ if (type is null)
+ {
+ logger.LogWarning(
+ "Skipping item {ItemId} with unknown type '{ItemType}'. This may indicate a removed plugin or database corruption.",
+ baseItemEntity.Id,
+ baseItemEntity.Type);
+ return null;
+ }
+
BaseItemDto? dto = null;
if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization)
{
@@ -1338,10 +1404,9 @@ public sealed class BaseItemRepository
.. resultQuery
.AsEnumerable()
.Where(e => e is not null)
- .Select(e =>
- {
- return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount);
- })
+ .Select(e => (Item: DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount))
+ .Where(e => e.Item is not null)
+ .Select(e => (e.Item!, e.itemCount))
];
}
else
@@ -1352,10 +1417,9 @@ public sealed class BaseItemRepository
.. query
.AsEnumerable()
.Where(e => e is not null)
- .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e =>
- {
- return (DeserializeBaseItem(e, filter.SkipDeserialization), null);
- })
+ .Select(e => (Item: DeserializeBaseItem(e, filter.SkipDeserialization), ItemCounts: (ItemCounts?)null))
+ .Where(e => e.Item is not null)
+ .Select(e => (e.Item!, e.ItemCounts))
];
}
@@ -1571,43 +1635,50 @@ public sealed class BaseItemRepository
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
{
- var orderBy = filter.OrderBy;
+ var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
if (hasSearch)
{
- orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
+ orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
}
- else if (orderBy.Count == 0)
+ else if (orderBy.Length == 0)
{
return query.OrderBy(e => e.SortName);
}
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
+ // When searching, prioritize by match quality: exact match > prefix match > contains
+ if (hasSearch)
+ {
+ orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!));
+ }
+
var firstOrdering = orderBy.FirstOrDefault();
if (firstOrdering != default)
{
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
- if (firstOrdering.SortOrder == SortOrder.Ascending)
+ if (orderedQuery is null)
{
- orderedQuery = query.OrderBy(expression);
+ // No search relevance ordering, start fresh
+ orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
+ ? query.OrderBy(expression)
+ : query.OrderByDescending(expression);
}
else
{
- orderedQuery = query.OrderByDescending(expression);
+ // Search relevance ordering already applied, chain with ThenBy
+ orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
+ ? orderedQuery.ThenBy(expression)
+ : orderedQuery.ThenByDescending(expression);
}
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
{
- if (firstOrdering.SortOrder is SortOrder.Ascending)
- {
- orderedQuery = orderedQuery.ThenBy(e => e.Name);
- }
- else
- {
- orderedQuery = orderedQuery.ThenByDescending(e => e.Name);
- }
+ orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending
+ ? orderedQuery.ThenBy(e => e.Name)
+ : orderedQuery.ThenByDescending(e => e.Name);
}
}
@@ -1972,8 +2043,15 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Name))
{
- var cleanName = GetCleanValue(filter.Name);
- baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
+ if (filter.UseRawName == true)
+ {
+ baseQuery = baseQuery.Where(e => e.Name == filter.Name);
+ }
+ else
+ {
+ var cleanName = GetCleanValue(filter.Name);
+ baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
+ }
}
// These are the same, for now
@@ -2467,35 +2545,24 @@ public sealed class BaseItemRepository
if (filter.ExcludeInheritedTags.Length > 0)
{
+ var excludedTags = filter.ExcludeInheritedTags;
baseQuery = baseQuery.Where(e =>
- !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))
- && (e.Type != _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode] || !e.SeriesId.HasValue ||
- !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))));
+ !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
+ && (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
}
if (filter.IncludeInheritedTags.Length > 0)
{
- // For seasons and episodes, we also need to check the parent series' tags.
- if (includeTypes.Any(t => t == BaseItemKind.Episode || t == BaseItemKind.Season))
- {
- baseQuery = baseQuery.Where(e =>
- e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
- || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
- }
+ var includeTags = filter.IncludeInheritedTags;
+ var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
+ baseQuery = baseQuery.Where(e =>
+ e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
- // A playlist should be accessible to its owner regardless of allowed tags.
- else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
- {
- baseQuery = baseQuery.Where(e =>
- e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
- || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
- // d ^^ this is stupid it hate this.
- }
- else
- {
- baseQuery = baseQuery.Where(e =>
- e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
- }
+ // For seasons and episodes, we also need to check the parent series' tags.
+ || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
+
+ // A playlist should be accessible to its owner regardless of allowed tags
+ || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
}
if (filter.SeriesStatuses.Length > 0)
@@ -2649,6 +2716,21 @@ public sealed class BaseItemRepository
.Where(e => artistNames.Contains(e.Name))
.ToArray();
- return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray());
+ var lookup = artists
+ .GroupBy(e => e.Name!)
+ .ToDictionary(
+ g => g.Key,
+ g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
+
+ var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
+ foreach (var name in artistNames)
+ {
+ if (lookup.TryGetValue(name, out var artistArray))
+ {
+ result[name] = artistArray;
+ }
+ }
+
+ return result;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
index 7eb13b740..64874ccad 100644
--- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
@@ -158,6 +158,12 @@ public class MediaStreamRepository : IMediaStreamRepository
dto.LocalizedDefault = _localization.GetLocalizedString("Default");
dto.LocalizedExternal = _localization.GetLocalizedString("External");
+ if (!string.IsNullOrEmpty(dto.Language))
+ {
+ var culture = _localization.FindLanguageInfo(dto.Language);
+ dto.LocalizedLanguage = culture?.DisplayName;
+ }
+
if (dto.Type is MediaStreamType.Subtitle)
{
dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined");
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index 192ee7499..1ae7cc6c4 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -6,6 +6,7 @@ using System.Linq.Expressions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using Microsoft.EntityFrameworkCore;
@@ -68,4 +69,30 @@ public static class OrderMapper
_ => e => e.SortName
};
}
+
+ /// <summary>
+ /// Creates an expression to order search results by match quality.
+ /// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
+ /// </summary>
+ /// <param name="searchTerm">The search term to match against.</param>
+ /// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
+ public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm)
+ {
+ var cleanSearchTerm = GetCleanValue(searchTerm);
+ var searchPrefix = cleanSearchTerm + " ";
+ return e =>
+ e.CleanName == cleanSearchTerm ? 0 :
+ e.CleanName!.StartsWith(searchPrefix) ? 1 :
+ e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
+ }
+
+ private static string GetCleanValue(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+
+ return value.RemoveDiacritics().ToLowerInvariant();
+ }
}
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 355ed6479..e2569241d 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -74,9 +74,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
/// <inheritdoc />
public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people)
{
- foreach (var item in people.Where(e => e.Role is null))
+ foreach (var person in people)
{
- item.Role = string.Empty;
+ person.Name = person.Name.Trim();
+ person.Role = person.Role?.Trim() ?? string.Empty;
}
// multiple metadata providers can provide the _same_ person
diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
index 6693ab8db..4f0c37722 100644
--- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
+++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
@@ -27,7 +27,6 @@
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" />
- <PackageReference Include="System.Linq.Async" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
</ItemGroup>
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 63d962ad9..501cb4fbe 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users
ThrowIfInvalidUsername(newName);
- if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase))
+ if (user.Username.Equals(newName, StringComparison.Ordinal))
{
throw new ArgumentException("The new and old names must be different.");
}
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index 9fd853cf2..2aadedfa6 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -3,7 +3,7 @@ using Jellyfin.Api.Middleware;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
namespace Jellyfin.Server.Extensions
{
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index 04dd19eda..c71c193e2 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -1,11 +1,11 @@
using System;
using System.Collections.Generic;
using System.IO;
-using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Security.Claims;
+using System.Text.Json.Nodes;
using Emby.Server.Implementations;
using Jellyfin.Api.Auth;
using Jellyfin.Api.Auth.AnonymousLanAccessPolicy;
@@ -26,7 +26,6 @@ using Jellyfin.Server.Filters;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Net;
using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Session;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
@@ -34,9 +33,7 @@ using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
-using Microsoft.OpenApi.Any;
-using Microsoft.OpenApi.Interfaces;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
@@ -174,7 +171,7 @@ namespace Jellyfin.Server.Extensions
if (config.KnownProxies.Length == 0)
{
options.ForwardedHeaders = ForwardedHeaders.None;
- options.KnownNetworks.Clear();
+ options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
}
else
@@ -184,7 +181,7 @@ namespace Jellyfin.Server.Extensions
}
// Only set forward limit if we have some known proxies or some known networks.
- if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0)
+ if (options.KnownProxies.Count != 0 || options.KnownIPNetworks.Count != 0)
{
options.ForwardLimit = null;
}
@@ -208,7 +205,7 @@ namespace Jellyfin.Server.Extensions
{
{
"x-jellyfin-version",
- new OpenApiString(version)
+ new JsonNodeExtension(JsonValue.Create(version))
}
}
});
@@ -255,12 +252,14 @@ namespace Jellyfin.Server.Extensions
c.AddSwaggerTypeMappings();
c.SchemaFilter<IgnoreEnumSchemaFilter>();
+ c.SchemaFilter<FlagsEnumSchemaFilter>();
c.OperationFilter<RetryOnTemporarilyUnavailableFilter>();
c.OperationFilter<SecurityRequirementsOperationFilter>();
c.OperationFilter<FileResponseFilter>();
c.OperationFilter<FileRequestFilter>();
c.OperationFilter<ParameterObsoleteFilter>();
c.DocumentFilter<AdditionalModelFilter>();
+ c.DocumentFilter<SecuritySchemeReferenceFixupFilter>();
})
.Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>());
}
@@ -289,10 +288,7 @@ namespace Jellyfin.Server.Extensions
}
else if (NetworkUtils.TryParseToSubnet(allowedProxies[i], out var subnet))
{
- if (subnet is not null)
- {
- AddIPAddress(config, options, subnet.Prefix, subnet.PrefixLength);
- }
+ AddIPAddress(config, options, subnet.Address, subnet.Subnet.PrefixLength);
}
else if (NetworkUtils.TryParseHost(allowedProxies[i], out var addresses, config.EnableIPv4, config.EnableIPv6))
{
@@ -322,7 +318,7 @@ namespace Jellyfin.Server.Extensions
}
else
{
- options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(addr, prefixLength));
+ options.KnownIPNetworks.Add(new System.Net.IPNetwork(addr, prefixLength));
}
}
@@ -335,63 +331,28 @@ namespace Jellyfin.Server.Extensions
options.MapType<Dictionary<ImageType, string>>(() =>
new OpenApiSchema
{
- Type = "object",
+ Type = JsonSchemaType.Object,
AdditionalProperties = new OpenApiSchema
{
- Type = "string"
+ Type = JsonSchemaType.String
}
});
- /*
- * Support BlurHash dictionary
- */
- options.MapType<Dictionary<ImageType, Dictionary<string, string>>>(() =>
- new OpenApiSchema
- {
- Type = "object",
- Properties = typeof(ImageType).GetEnumNames().ToDictionary(
- name => name,
- _ => new OpenApiSchema
- {
- Type = "object",
- AdditionalProperties = new OpenApiSchema
- {
- Type = "string"
- }
- })
- });
-
// Support dictionary with nullable string value.
options.MapType<Dictionary<string, string?>>(() =>
new OpenApiSchema
{
- Type = "object",
+ Type = JsonSchemaType.Object,
AdditionalProperties = new OpenApiSchema
{
- Type = "string",
- Nullable = true
- }
- });
-
- // Manually describe Flags enum.
- options.MapType<TranscodeReason>(() =>
- new OpenApiSchema
- {
- Type = "array",
- Items = new OpenApiSchema
- {
- Reference = new OpenApiReference
- {
- Id = nameof(TranscodeReason),
- Type = ReferenceType.Schema,
- }
+ Type = JsonSchemaType.String | JsonSchemaType.Null
}
});
// Swashbuckle doesn't use JsonOptions to describe responses, so we need to manually describe it.
options.MapType<Version>(() => new OpenApiSchema
{
- Type = "string"
+ Type = JsonSchemaType.String
});
}
}
diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs
index 58d37db5a..efa2f4cca 100644
--- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs
+++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs
@@ -3,18 +3,17 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
+using System.Text.Json.Nodes;
using Jellyfin.Extensions;
using Jellyfin.Server.Migrations;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Net.WebSocketMessages;
-using MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
using MediaBrowser.Model.ApiClient;
using MediaBrowser.Model.Session;
using MediaBrowser.Model.SyncPlay;
-using Microsoft.OpenApi.Any;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters
@@ -25,7 +24,7 @@ namespace Jellyfin.Server.Filters
public class AdditionalModelFilter : IDocumentFilter
{
// Array of options that should not be visible in the api spec.
- private static readonly Type[] _ignoredConfigurations = { typeof(MigrationOptions), typeof(MediaBrowser.Model.Branding.BrandingOptions) };
+ private static readonly Type[] _ignoredConfigurations = [typeof(MigrationOptions), typeof(MediaBrowser.Model.Branding.BrandingOptions)];
private readonly IServerConfigurationManager _serverConfigurationManager;
/// <summary>
@@ -48,8 +47,8 @@ namespace Jellyfin.Server.Filters
&& t != typeof(WebSocketMessageInfo))
.ToList();
- var inboundWebSocketSchemas = new List<OpenApiSchema>();
- var inboundWebSocketDiscriminators = new Dictionary<string, string>();
+ var inboundWebSocketSchemas = new List<IOpenApiSchema>();
+ var inboundWebSocketDiscriminators = new Dictionary<string, OpenApiSchemaReference>();
foreach (var type in webSocketTypes.Where(t => typeof(IInboundWebSocketMessage).IsAssignableFrom(t)))
{
var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value;
@@ -60,18 +59,16 @@ namespace Jellyfin.Server.Filters
var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
inboundWebSocketSchemas.Add(schema);
- inboundWebSocketDiscriminators[messageType.ToString()!] = schema.Reference.ReferenceV3;
+ if (schema is OpenApiSchemaReference schemaRef)
+ {
+ inboundWebSocketDiscriminators[messageType.ToString()!] = schemaRef;
+ }
}
var inboundWebSocketMessageSchema = new OpenApiSchema
{
- Type = "object",
+ Type = JsonSchemaType.Object,
Description = "Represents the list of possible inbound websocket types",
- Reference = new OpenApiReference
- {
- Id = nameof(InboundWebSocketMessage),
- Type = ReferenceType.Schema
- },
OneOf = inboundWebSocketSchemas,
Discriminator = new OpenApiDiscriminator
{
@@ -82,8 +79,8 @@ namespace Jellyfin.Server.Filters
context.SchemaRepository.AddDefinition(nameof(InboundWebSocketMessage), inboundWebSocketMessageSchema);
- var outboundWebSocketSchemas = new List<OpenApiSchema>();
- var outboundWebSocketDiscriminators = new Dictionary<string, string>();
+ var outboundWebSocketSchemas = new List<IOpenApiSchema>();
+ var outboundWebSocketDiscriminators = new Dictionary<string, OpenApiSchemaReference>();
foreach (var type in webSocketTypes.Where(t => typeof(IOutboundWebSocketMessage).IsAssignableFrom(t)))
{
var messageType = (SessionMessageType?)type.GetProperty(nameof(WebSocketMessage.MessageType))?.GetCustomAttribute<DefaultValueAttribute>()?.Value;
@@ -94,58 +91,55 @@ namespace Jellyfin.Server.Filters
var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
outboundWebSocketSchemas.Add(schema);
- outboundWebSocketDiscriminators.Add(messageType.ToString()!, schema.Reference.ReferenceV3);
+ if (schema is OpenApiSchemaReference schemaRef)
+ {
+ outboundWebSocketDiscriminators.Add(messageType.ToString()!, schemaRef);
+ }
}
// Add custom "SyncPlayGroupUpdateMessage" schema because Swashbuckle cannot generate it for us
var syncPlayGroupUpdateMessageSchema = new OpenApiSchema
{
- Type = "object",
+ Type = JsonSchemaType.Object,
Description = "Untyped sync play command.",
- Properties = new Dictionary<string, OpenApiSchema>
+ Properties = new Dictionary<string, IOpenApiSchema>
{
{
"Data", new OpenApiSchema
{
- AllOf =
- [
- new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(GroupUpdate<object>) } }
- ],
+ AllOf = new List<IOpenApiSchema>
+ {
+ new OpenApiSchemaReference(nameof(GroupUpdate<object>), null, null)
+ },
Description = "Group update data",
- Nullable = false,
}
},
- { "MessageId", new OpenApiSchema { Type = "string", Format = "uuid", Description = "Gets or sets the message id." } },
+ { "MessageId", new OpenApiSchema { Type = JsonSchemaType.String, Format = "uuid", Description = "Gets or sets the message id." } },
{
"MessageType", new OpenApiSchema
{
- Enum = Enum.GetValues<SessionMessageType>().Select(type => new OpenApiString(type.ToString())).ToList<IOpenApiAny>(),
- AllOf =
- [
- new OpenApiSchema { Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = nameof(SessionMessageType) } }
- ],
+ Enum = Enum.GetValues<SessionMessageType>().Select(type => (JsonNode)JsonValue.Create(type.ToString())!).ToList(),
+ AllOf = new List<IOpenApiSchema>
+ {
+ new OpenApiSchemaReference(nameof(SessionMessageType), null, null)
+ },
Description = "The different kinds of messages that are used in the WebSocket api.",
- Default = new OpenApiString(nameof(SessionMessageType.SyncPlayGroupUpdate)),
+ Default = JsonValue.Create(nameof(SessionMessageType.SyncPlayGroupUpdate)),
ReadOnly = true
}
},
},
AdditionalPropertiesAllowed = false,
- Reference = new OpenApiReference { Type = ReferenceType.Schema, Id = "SyncPlayGroupUpdateMessage" }
};
context.SchemaRepository.AddDefinition("SyncPlayGroupUpdateMessage", syncPlayGroupUpdateMessageSchema);
- outboundWebSocketSchemas.Add(syncPlayGroupUpdateMessageSchema);
- outboundWebSocketDiscriminators[nameof(SessionMessageType.SyncPlayGroupUpdate)] = syncPlayGroupUpdateMessageSchema.Reference.ReferenceV3;
+ var syncPlayRef = new OpenApiSchemaReference("SyncPlayGroupUpdateMessage", null, null);
+ outboundWebSocketSchemas.Add(syncPlayRef);
+ outboundWebSocketDiscriminators[nameof(SessionMessageType.SyncPlayGroupUpdate)] = syncPlayRef;
var outboundWebSocketMessageSchema = new OpenApiSchema
{
- Type = "object",
+ Type = JsonSchemaType.Object,
Description = "Represents the list of possible outbound websocket types",
- Reference = new OpenApiReference
- {
- Id = nameof(OutboundWebSocketMessage),
- Type = ReferenceType.Schema
- },
OneOf = outboundWebSocketSchemas,
Discriminator = new OpenApiDiscriminator
{
@@ -159,17 +153,12 @@ namespace Jellyfin.Server.Filters
nameof(WebSocketMessage),
new OpenApiSchema
{
- Type = "object",
+ Type = JsonSchemaType.Object,
Description = "Represents the possible websocket types",
- Reference = new OpenApiReference
+ OneOf = new List<IOpenApiSchema>
{
- Id = nameof(WebSocketMessage),
- Type = ReferenceType.Schema
- },
- OneOf = new[]
- {
- inboundWebSocketMessageSchema,
- outboundWebSocketMessageSchema
+ new OpenApiSchemaReference(nameof(InboundWebSocketMessage), null, null),
+ new OpenApiSchemaReference(nameof(OutboundWebSocketMessage), null, null)
}
});
@@ -180,8 +169,8 @@ namespace Jellyfin.Server.Filters
&& t.BaseType.GetGenericTypeDefinition() == typeof(GroupUpdate<>))
.ToList();
- var groupUpdateSchemas = new List<OpenApiSchema>();
- var groupUpdateDiscriminators = new Dictionary<string, string>();
+ var groupUpdateSchemas = new List<IOpenApiSchema>();
+ var groupUpdateDiscriminators = new Dictionary<string, OpenApiSchemaReference>();
foreach (var type in groupUpdateTypes)
{
var groupUpdateType = (GroupUpdateType?)type.GetProperty(nameof(GroupUpdate<object>.Type))?.GetCustomAttribute<DefaultValueAttribute>()?.Value;
@@ -192,18 +181,16 @@ namespace Jellyfin.Server.Filters
var schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
groupUpdateSchemas.Add(schema);
- groupUpdateDiscriminators[groupUpdateType.ToString()!] = schema.Reference.ReferenceV3;
+ if (schema is OpenApiSchemaReference schemaRef)
+ {
+ groupUpdateDiscriminators[groupUpdateType.ToString()!] = schemaRef;
+ }
}
var groupUpdateSchema = new OpenApiSchema
{
- Type = "object",
+ Type = JsonSchemaType.Object,
Description = "Represents the list of possible group update types",
- Reference = new OpenApiReference
- {
- Id = nameof(GroupUpdate<object>),
- Type = ReferenceType.Schema
- },
OneOf = groupUpdateSchemas,
Discriminator = new OpenApiDiscriminator
{
@@ -225,15 +212,6 @@ namespace Jellyfin.Server.Filters
context.SchemaGenerator.GenerateSchema(configuration.ConfigurationType, context.SchemaRepository);
}
-
- context.SchemaRepository.AddDefinition(nameof(TranscodeReason), new OpenApiSchema
- {
- Type = "string",
- Enum = Enum.GetNames<TranscodeReason>()
- .Select(e => new OpenApiString(e))
- .Cast<IOpenApiAny>()
- .ToArray()
- });
}
}
}
diff --git a/Jellyfin.Server/Filters/CachingOpenApiProvider.cs b/Jellyfin.Server/Filters/CachingOpenApiProvider.cs
index b560ec50e..fdc49a984 100644
--- a/Jellyfin.Server/Filters/CachingOpenApiProvider.cs
+++ b/Jellyfin.Server/Filters/CachingOpenApiProvider.cs
@@ -2,8 +2,9 @@ using System;
using AsyncKeyedLock;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;
@@ -23,6 +24,7 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider
private readonly IMemoryCache _memoryCache;
private readonly SwaggerGenerator _swaggerGenerator;
private readonly SwaggerGeneratorOptions _swaggerGeneratorOptions;
+ private readonly ILogger<CachingOpenApiProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CachingOpenApiProvider"/> class.
@@ -31,19 +33,22 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider
/// <param name="apiDescriptionsProvider">The api descriptions provider.</param>
/// <param name="schemaGenerator">The schema generator.</param>
/// <param name="memoryCache">The memory cache.</param>
+ /// <param name="logger">The logger.</param>
public CachingOpenApiProvider(
IOptions<SwaggerGeneratorOptions> optionsAccessor,
IApiDescriptionGroupCollectionProvider apiDescriptionsProvider,
ISchemaGenerator schemaGenerator,
- IMemoryCache memoryCache)
+ IMemoryCache memoryCache,
+ ILogger<CachingOpenApiProvider> logger)
{
_swaggerGeneratorOptions = optionsAccessor.Value;
_swaggerGenerator = new SwaggerGenerator(_swaggerGeneratorOptions, apiDescriptionsProvider, schemaGenerator);
_memoryCache = memoryCache;
+ _logger = logger;
}
/// <inheritdoc />
- public OpenApiDocument GetSwagger(string documentName, string? host = null, string? basePath = null)
+ public OpenApiDocument GetSwagger(string documentName, string host, string basePath)
{
if (_memoryCache.TryGetValue(CacheKey, out OpenApiDocument? openApiDocument) && openApiDocument is not null)
{
@@ -61,7 +66,16 @@ internal sealed class CachingOpenApiProvider : ISwaggerProvider
throw new InvalidOperationException("OpenApi document is generating");
}
+ try
+ {
openApiDocument = _swaggerGenerator.GetSwagger(documentName);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "OpenAPI generation error");
+ throw;
+ }
+
_memoryCache.Set(CacheKey, openApiDocument, _cacheOptions);
return AdjustDocument(openApiDocument, host, basePath);
}
diff --git a/Jellyfin.Server/Filters/FileRequestFilter.cs b/Jellyfin.Server/Filters/FileRequestFilter.cs
index 86dbf7657..3d5b1fdf1 100644
--- a/Jellyfin.Server/Filters/FileRequestFilter.cs
+++ b/Jellyfin.Server/Filters/FileRequestFilter.cs
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using Jellyfin.Api.Attributes;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters
@@ -28,10 +28,11 @@ namespace Jellyfin.Server.Filters
{
Schema = new OpenApiSchema
{
- Type = "string",
+ Type = JsonSchemaType.String,
Format = "binary"
}
};
+ body.Content ??= new System.Collections.Generic.Dictionary<string, OpenApiMediaType>();
foreach (var contentType in contentTypes)
{
body.Content.Add(contentType, mediaType);
diff --git a/Jellyfin.Server/Filters/FileResponseFilter.cs b/Jellyfin.Server/Filters/FileResponseFilter.cs
index cd0acadf3..64aea6251 100644
--- a/Jellyfin.Server/Filters/FileResponseFilter.cs
+++ b/Jellyfin.Server/Filters/FileResponseFilter.cs
@@ -1,7 +1,7 @@
using System;
using System.Linq;
using Jellyfin.Api.Attributes;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters
@@ -14,7 +14,7 @@ namespace Jellyfin.Server.Filters
{
Schema = new OpenApiSchema
{
- Type = "string",
+ Type = JsonSchemaType.String,
Format = "binary"
}
};
@@ -22,6 +22,11 @@ namespace Jellyfin.Server.Filters
/// <inheritdoc />
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
+ if (operation.Responses is null)
+ {
+ return;
+ }
+
foreach (var attribute in context.ApiDescription.ActionDescriptor.EndpointMetadata)
{
if (attribute is ProducesFileAttribute producesFileAttribute)
@@ -31,7 +36,7 @@ namespace Jellyfin.Server.Filters
.FirstOrDefault(o => o.Key.Equals(SuccessCode, StringComparison.Ordinal));
// Operation doesn't have a response.
- if (response.Value is null)
+ if (response.Value?.Content is null)
{
continue;
}
diff --git a/Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs b/Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs
new file mode 100644
index 000000000..0c1f4197c
--- /dev/null
+++ b/Jellyfin.Server/Filters/FlagsEnumSchemaFilter.cs
@@ -0,0 +1,58 @@
+using System;
+using Microsoft.OpenApi;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Jellyfin.Server.Filters;
+
+/// <summary>
+/// Schema filter to ensure flags enums are represented correctly in OpenAPI.
+/// </summary>
+/// <remarks>
+/// For flags enums:
+/// - The enum schema definition is set to type "string" (not integer).
+/// - Properties using flags enums are transformed to arrays referencing the enum schema.
+/// </remarks>
+public class FlagsEnumSchemaFilter : ISchemaFilter
+{
+ /// <inheritdoc />
+ public void Apply(IOpenApiSchema schema, SchemaFilterContext context)
+ {
+ var type = context.Type.IsEnum ? context.Type : Nullable.GetUnderlyingType(context.Type);
+ if (type is null || !type.IsEnum)
+ {
+ return;
+ }
+
+ // Check if enum has [Flags] attribute
+ if (!type.IsDefined(typeof(FlagsAttribute), false))
+ {
+ return;
+ }
+
+ if (schema is not OpenApiSchema concreteSchema)
+ {
+ return;
+ }
+
+ if (context.MemberInfo is null)
+ {
+ // Processing the enum definition itself - ensure it's type "string" not "integer"
+ concreteSchema.Type = JsonSchemaType.String;
+ concreteSchema.Format = null;
+ }
+ else
+ {
+ // Processing a property that uses the flags enum - transform to array
+ // Generate the enum schema to ensure it exists in the repository
+ var enumSchema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
+
+ // Flags enums should be represented as arrays referencing the enum schema
+ // since multiple values can be combined
+ concreteSchema.Type = JsonSchemaType.Array;
+ concreteSchema.Format = null;
+ concreteSchema.Enum = null;
+ concreteSchema.AllOf = null;
+ concreteSchema.Items = enumSchema;
+ }
+ }
+}
diff --git a/Jellyfin.Server/Filters/IgnoreEnumSchemaFilter.cs b/Jellyfin.Server/Filters/IgnoreEnumSchemaFilter.cs
index eb9ad03c2..3dcf29d9c 100644
--- a/Jellyfin.Server/Filters/IgnoreEnumSchemaFilter.cs
+++ b/Jellyfin.Server/Filters/IgnoreEnumSchemaFilter.cs
@@ -2,9 +2,9 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
+using System.Text.Json.Nodes;
using Jellyfin.Data.Attributes;
-using Microsoft.OpenApi.Any;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters;
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Filters;
public class IgnoreEnumSchemaFilter : ISchemaFilter
{
/// <inheritdoc />
- public void Apply(OpenApiSchema schema, SchemaFilterContext context)
+ public void Apply(IOpenApiSchema schema, SchemaFilterContext context)
{
if (context.Type.IsEnum || (Nullable.GetUnderlyingType(context.Type)?.IsEnum ?? false))
{
@@ -25,18 +25,23 @@ public class IgnoreEnumSchemaFilter : ISchemaFilter
return;
}
- var enumOpenApiStrings = new List<IOpenApiAny>();
+ if (schema is not OpenApiSchema concreteSchema)
+ {
+ return;
+ }
+
+ var enumOpenApiNodes = new List<JsonNode>();
foreach (var enumName in Enum.GetNames(type))
{
var member = type.GetMember(enumName)[0];
if (!member.GetCustomAttributes<OpenApiIgnoreEnumAttribute>().Any())
{
- enumOpenApiStrings.Add(new OpenApiString(enumName));
+ enumOpenApiNodes.Add(JsonValue.Create(enumName)!);
}
}
- schema.Enum = enumOpenApiStrings;
+ concreteSchema.Enum = enumOpenApiNodes;
}
}
}
diff --git a/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs b/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs
index 98a8dc0f1..90bca884b 100644
--- a/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs
+++ b/Jellyfin.Server/Filters/ParameterObsoleteFilter.cs
@@ -1,7 +1,7 @@
using System;
using System.Linq;
using Jellyfin.Api.Attributes;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters
@@ -21,11 +21,17 @@ namespace Jellyfin.Server.Filters
.OfType<ParameterObsoleteAttribute>()
.Any())
{
+ if (operation.Parameters is null)
+ {
+ continue;
+ }
+
foreach (var parameter in operation.Parameters)
{
- if (parameter.Name.Equals(parameterDescription.Name, StringComparison.Ordinal))
+ if (parameter is OpenApiParameter concreteParam
+ && string.Equals(concreteParam.Name, parameterDescription.Name, StringComparison.Ordinal))
{
- parameter.Deprecated = true;
+ concreteParam.Deprecated = true;
break;
}
}
diff --git a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs
index 8b7268513..435f55496 100644
--- a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs
+++ b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs
@@ -1,5 +1,5 @@
using System.Collections.Generic;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters;
@@ -8,12 +8,12 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
- operation.Responses.TryAdd(
+ operation.Responses?.TryAdd(
"503",
new OpenApiResponse
{
Description = "The server is currently starting or is temporarily not available.",
- Headers = new Dictionary<string, OpenApiHeader>
+ Headers = new Dictionary<string, IOpenApiHeader>
{
{
"Retry-After", new OpenApiHeader
@@ -23,7 +23,7 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
Description = "A hint for when to retry the operation in full seconds.",
Schema = new OpenApiSchema
{
- Type = "integer",
+ Type = JsonSchemaType.Integer,
Format = "int32"
}
}
@@ -36,7 +36,7 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
Description = "A short plain-text reason why the server is not available.",
Schema = new OpenApiSchema
{
- Type = "string",
+ Type = JsonSchemaType.String,
Format = "text"
}
}
diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
index 8f5757269..5b048be91 100644
--- a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
+++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
@@ -5,7 +5,7 @@ using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
using Jellyfin.Api.Constants;
using Jellyfin.Extensions;
using Microsoft.AspNetCore.Authorization;
-using Microsoft.OpenApi.Models;
+using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters;
@@ -66,17 +66,10 @@ public class SecurityRequirementsOperationFilter : IOperationFilter
return;
}
- operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
- operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });
+ operation.Responses?.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
+ operation.Responses?.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });
- var scheme = new OpenApiSecurityScheme
- {
- Reference = new OpenApiReference
- {
- Type = ReferenceType.SecurityScheme,
- Id = AuthenticationSchemes.CustomAuthentication
- },
- };
+ var scheme = new OpenApiSecuritySchemeReference(AuthenticationSchemes.CustomAuthentication, null, null);
// Add DefaultAuthorization scope to any endpoint that has a policy with a requirement that is a subset of DefaultAuthorization.
if (!requiredScopes.Contains(DefaultAuthPolicy.AsSpan(), StringComparison.Ordinal))
diff --git a/Jellyfin.Server/Filters/SecuritySchemeReferenceFixupFilter.cs b/Jellyfin.Server/Filters/SecuritySchemeReferenceFixupFilter.cs
new file mode 100644
index 000000000..e4eb5be2b
--- /dev/null
+++ b/Jellyfin.Server/Filters/SecuritySchemeReferenceFixupFilter.cs
@@ -0,0 +1,56 @@
+using Microsoft.OpenApi;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Jellyfin.Server.Filters;
+
+/// <summary>
+/// Document filter that fixes security scheme references after document generation.
+/// </summary>
+/// <remarks>
+/// In Microsoft.OpenApi v2, <see cref="OpenApiSecuritySchemeReference"/> requires a resolved
+/// <c>Target</c> to serialize correctly. References created without a host document (as in
+/// operation filters) serialize as empty objects. This filter re-creates all security scheme
+/// references with the document context so they resolve properly during serialization.
+/// </remarks>
+internal class SecuritySchemeReferenceFixupFilter : IDocumentFilter
+{
+ /// <inheritdoc />
+ public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
+ {
+ swaggerDoc.RegisterComponents();
+
+ if (swaggerDoc.Paths is null)
+ {
+ return;
+ }
+
+ foreach (var pathItem in swaggerDoc.Paths.Values)
+ {
+ if (pathItem.Operations is null)
+ {
+ continue;
+ }
+
+ foreach (var operation in pathItem.Operations.Values)
+ {
+ if (operation.Security is null)
+ {
+ continue;
+ }
+
+ for (int i = 0; i < operation.Security.Count; i++)
+ {
+ var oldReq = operation.Security[i];
+ var newReq = new OpenApiSecurityRequirement();
+ foreach (var kvp in oldReq)
+ {
+ var fixedRef = new OpenApiSecuritySchemeReference(kvp.Key.Reference.Id!, swaggerDoc);
+ newReq[fixedRef] = kvp.Value;
+ }
+
+ operation.Security[i] = newReq;
+ }
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index 14ab114fb..9f5bf01a0 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -8,7 +8,7 @@
<PropertyGroup>
<AssemblyName>jellyfin</AssemblyName>
<OutputType>Exe</OutputType>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<ServerGarbageCollection>false</ServerGarbageCollection>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
@@ -44,9 +44,6 @@
<ItemGroup>
<PackageReference Include="CommandLineParser" />
- <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
- <PackageReference Include="Microsoft.Extensions.Configuration.Json" />
- <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
<PackageReference Include="Morestachio" />
<PackageReference Include="prometheus-net" />
diff --git a/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs b/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs
new file mode 100644
index 000000000..e82123e5a
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/FixLibrarySubtitleDownloadLanguages.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Globalization;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to fix broken library subtitle download languages.
+/// </summary>
+[JellyfinMigration("2026-02-06T20:00:00", nameof(FixLibrarySubtitleDownloadLanguages))]
+internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
+{
+ private readonly ILocalizationManager _localizationManager;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FixLibrarySubtitleDownloadLanguages"/> class.
+ /// </summary>
+ /// <param name="localizationManager">The Localization manager.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI integration.</param>
+ /// <param name="libraryManager">The Library manager.</param>
+ /// <param name="logger">The logger.</param>
+ public FixLibrarySubtitleDownloadLanguages(
+ ILocalizationManager localizationManager,
+ IStartupLogger<FixLibrarySubtitleDownloadLanguages> startupLogger,
+ ILibraryManager libraryManager,
+ ILogger<FixLibrarySubtitleDownloadLanguages> logger)
+ {
+ _localizationManager = localizationManager;
+ _libraryManager = libraryManager;
+ _logger = startupLogger.With(logger);
+ }
+
+ /// <inheritdoc />
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Starting to fix library subtitle download languages.");
+
+ var virtualFolders = _libraryManager.GetVirtualFolders(false);
+
+ foreach (var virtualFolder in virtualFolders)
+ {
+ var options = virtualFolder.LibraryOptions;
+ if (options.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
+ {
+ continue;
+ }
+
+ // Some virtual folders don't have a proper item id.
+ if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
+ {
+ continue;
+ }
+
+ var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
+ if (collectionFolder is null)
+ {
+ _logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
+ continue;
+ }
+
+ var fixedLanguages = new List<string>();
+
+ foreach (var language in options.SubtitleDownloadLanguages)
+ {
+ var foundLanguage = _localizationManager.FindLanguageInfo(language)?.ThreeLetterISOLanguageName;
+ if (foundLanguage is not null)
+ {
+ // Converted ISO 639-2/B to T (ger to deu)
+ if (!string.Equals(foundLanguage, language, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Converted '{Language}' to '{ResolvedLanguage}' in library '{LibraryName}'.", language, foundLanguage, virtualFolder.Name);
+ }
+
+ if (fixedLanguages.Contains(foundLanguage, StringComparer.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Language '{Language}' already exists for library '{LibraryName}'. Skipping duplicate.", foundLanguage, virtualFolder.Name);
+ continue;
+ }
+
+ fixedLanguages.Add(foundLanguage);
+ }
+ else
+ {
+ _logger.LogInformation("Could not resolve language '{Language}' in library '{LibraryName}'. Skipping.", language, virtualFolder.Name);
+ }
+ }
+
+ options.SubtitleDownloadLanguages = [.. fixedLanguages];
+ collectionFolder.UpdateLibraryOptions(options);
+ }
+
+ _logger.LogInformation("Library subtitle download languages fixed.");
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
index d221d1853..c6ac55b6e 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
@@ -464,6 +464,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
SqliteConnection.ClearAllPools();
+ using (var checkpointConnection = new SqliteConnection($"Filename={libraryDbPath}"))
+ {
+ checkpointConnection.Open();
+ using var cmd = checkpointConnection.CreateCommand();
+ cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
+ cmd.ExecuteNonQuery();
+ }
+
+ SqliteConnection.ClearAllPools();
+
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
File.Move(libraryDbPath, libraryDbPath + ".old", true);
}
@@ -1163,7 +1173,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))
@@ -1247,8 +1259,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
- var dataKeys = baseItem.GetUserDataKeys();
- userDataKeys.AddRange(dataKeys);
+ if (baseItem is not null)
+ {
+ var dataKeys = baseItem.GetUserDataKeys();
+ userDataKeys.AddRange(dataKeys);
+ }
return (entity, userDataKeys.ToArray());
}
diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj
index 5f15f845c..c128c2b6b 100644
--- a/MediaBrowser.Common/MediaBrowser.Common.csproj
+++ b/MediaBrowser.Common/MediaBrowser.Common.csproj
@@ -19,16 +19,11 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
- <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
- </ItemGroup>
-
- <ItemGroup>
<Compile Include="..\SharedVersion.cs" />
</ItemGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
diff --git a/MediaBrowser.Common/Net/NetworkConstants.cs b/MediaBrowser.Common/Net/NetworkConstants.cs
index ccef5d271..cec996a1a 100644
--- a/MediaBrowser.Common/Net/NetworkConstants.cs
+++ b/MediaBrowser.Common/Net/NetworkConstants.cs
@@ -1,5 +1,4 @@
using System.Net;
-using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
namespace MediaBrowser.Common.Net;
diff --git a/MediaBrowser.Common/Net/NetworkUtils.cs b/MediaBrowser.Common/Net/NetworkUtils.cs
index 24ed47a81..5c854b39d 100644
--- a/MediaBrowser.Common/Net/NetworkUtils.cs
+++ b/MediaBrowser.Common/Net/NetworkUtils.cs
@@ -6,7 +6,7 @@ using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using Jellyfin.Extensions;
-using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
+using MediaBrowser.Model.Net;
namespace MediaBrowser.Common.Net;
@@ -167,7 +167,7 @@ public static partial class NetworkUtils
/// <param name="result">Collection of <see cref="IPNetwork"/>.</param>
/// <param name="negated">Boolean signaling if negated or not negated values should be parsed.</param>
/// <returns><c>True</c> if parsing was successful.</returns>
- public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPNetwork>? result, bool negated = false)
+ public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPData>? result, bool negated = false)
{
if (values is null || values.Length == 0)
{
@@ -175,28 +175,28 @@ public static partial class NetworkUtils
return false;
}
- var tmpResult = new List<IPNetwork>();
+ List<IPData>? tmpResult = null;
for (int a = 0; a < values.Length; a++)
{
if (TryParseToSubnet(values[a], out var innerResult, negated))
{
- tmpResult.Add(innerResult);
+ (tmpResult ??= new()).Add(innerResult);
}
}
result = tmpResult;
- return tmpResult.Count > 0;
+ return result is not null;
}
/// <summary>
- /// Try parsing a string into an <see cref="IPNetwork"/>, respecting exclusions.
- /// Inputs without a subnet mask will be represented as <see cref="IPNetwork"/> with a single IP.
+ /// Try parsing a string into an <see cref="IPData"/>, respecting exclusions.
+ /// Inputs without a subnet mask will be represented as <see cref="IPData"/> with a single IP.
/// </summary>
/// <param name="value">Input string to be parsed.</param>
- /// <param name="result">An <see cref="IPNetwork"/>.</param>
+ /// <param name="result">An <see cref="IPData"/>.</param>
/// <param name="negated">Boolean signaling if negated or not negated values should be parsed.</param>
/// <returns><c>True</c> if parsing was successful.</returns>
- public static bool TryParseToSubnet(ReadOnlySpan<char> value, [NotNullWhen(true)] out IPNetwork? result, bool negated = false)
+ public static bool TryParseToSubnet(ReadOnlySpan<char> value, [NotNullWhen(true)] out IPData? result, bool negated = false)
{
// If multiple IP addresses are in a comma-separated string, the individual addresses may contain leading and/or trailing whitespace
value = value.Trim();
@@ -210,14 +210,16 @@ public static partial class NetworkUtils
if (isAddressNegated != negated)
{
- result = null;
+ result = default;
return false;
}
- if (value.Contains('/'))
+ var index = value.IndexOf('/');
+ if (index != -1)
{
- if (IPNetwork.TryParse(value, out result))
+ if (IPAddress.TryParse(value[..index], out var address) && IPNetwork.TryParse(value, out var subnet))
{
+ result = new IPData(address, subnet);
return true;
}
}
@@ -225,17 +227,17 @@ public static partial class NetworkUtils
{
if (address.AddressFamily == AddressFamily.InterNetwork)
{
- result = address.Equals(IPAddress.Any) ? NetworkConstants.IPv4Any : new IPNetwork(address, NetworkConstants.MinimumIPv4PrefixSize);
+ result = address.Equals(IPAddress.Any) ? new IPData(IPAddress.Any, NetworkConstants.IPv4Any) : new IPData(address, new IPNetwork(address, NetworkConstants.MinimumIPv4PrefixSize));
return true;
}
else if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
- result = address.Equals(IPAddress.IPv6Any) ? NetworkConstants.IPv6Any : new IPNetwork(address, NetworkConstants.MinimumIPv6PrefixSize);
+ result = address.Equals(IPAddress.IPv6Any) ? new IPData(IPAddress.IPv6Any, NetworkConstants.IPv6Any) : new IPData(address, new IPNetwork(address, NetworkConstants.MinimumIPv6PrefixSize));
return true;
}
}
- result = null;
+ result = default;
return false;
}
@@ -330,7 +332,7 @@ public static partial class NetworkUtils
/// <returns>The broadcast address.</returns>
public static IPAddress GetBroadcastAddress(IPNetwork network)
{
- var addressBytes = network.Prefix.GetAddressBytes();
+ var addressBytes = network.BaseAddress.GetAddressBytes();
uint ipAddress = BitConverter.ToUInt32(addressBytes, 0);
uint ipMaskV4 = BitConverter.ToUInt32(CidrToMask(network.PrefixLength, AddressFamily.InterNetwork).GetAddressBytes(), 0);
uint broadCastIPAddress = ipAddress | ~ipMaskV4;
@@ -347,7 +349,6 @@ public static partial class NetworkUtils
public static bool SubnetContainsAddress(IPNetwork network, IPAddress address)
{
ArgumentNullException.ThrowIfNull(address);
- ArgumentNullException.ThrowIfNull(network);
if (address.IsIPv4MappedToIPv6)
{
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 3c46d53e5..2404ace75 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -22,7 +22,6 @@ using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
@@ -1605,7 +1604,7 @@ namespace MediaBrowser.Controller.Entities
return !GetBlockUnratedValue(user);
}
- var ratingScore = LocalizationManager.GetRatingScore(rating);
+ var ratingScore = LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode());
// Could not determine rating level
if (ratingScore is null)
@@ -1620,12 +1619,17 @@ namespace MediaBrowser.Controller.Entities
return isAllowed;
}
- if (maxAllowedSubRating is not null)
+ if (!maxAllowedRating.HasValue)
{
- return (ratingScore.SubScore ?? 0) <= maxAllowedSubRating && ratingScore.Score <= maxAllowedRating.Value;
+ return true;
+ }
+
+ if (ratingScore.Score != maxAllowedRating.Value)
+ {
+ return ratingScore.Score < maxAllowedRating.Value;
}
- return !maxAllowedRating.HasValue || ratingScore.Score <= maxAllowedRating.Value;
+ return !maxAllowedSubRating.HasValue || (ratingScore.SubScore ?? 0) <= maxAllowedSubRating.Value;
}
public ParentalRatingScore GetParentalRatingScore()
@@ -1642,7 +1646,7 @@ namespace MediaBrowser.Controller.Entities
return null;
}
- return LocalizationManager.GetRatingScore(rating);
+ return LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode());
}
public List<string> GetInheritedTags()
@@ -2048,6 +2052,9 @@ namespace MediaBrowser.Controller.Entities
public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
=> await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false);
+ public async Task ReattachUserDataAsync(CancellationToken cancellationToken) =>
+ await LibraryManager.ReattachUserDataAsync(this, cancellationToken).ConfigureAwait(false);
+
/// <summary>
/// Validates that images within the item are still on the filesystem.
/// </summary>
@@ -2121,17 +2128,6 @@ namespace MediaBrowser.Controller.Entities
};
}
- // Music albums usually don't have dedicated backdrops, so return one from the artist instead
- if (GetType() == typeof(MusicAlbum) && imageType == ImageType.Backdrop)
- {
- var artist = FindParent<MusicArtist>();
-
- if (artist is not null)
- {
- return artist.GetImages(imageType).ElementAtOrDefault(imageIndex);
- }
- }
-
return GetImages(imageType)
.ElementAtOrDefault(imageIndex);
}
@@ -2613,7 +2609,7 @@ namespace MediaBrowser.Controller.Entities
.Select(i => i.OfficialRating)
.Where(i => !string.IsNullOrEmpty(i))
.Distinct(StringComparer.OrdinalIgnoreCase)
- .Select(rating => (rating, LocalizationManager.GetRatingScore(rating)))
+ .Select(rating => (rating, LocalizationManager.GetRatingScore(rating, GetPreferredMetadataCountryCode())))
.OrderBy(i => i.Item2 is null ? 1001 : i.Item2.Score)
.ThenBy(i => i.Item2 is null ? 1001 : i.Item2.SubScore)
.Select(i => i.rating);
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index 59a967725..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
{
@@ -1406,13 +1422,6 @@ namespace MediaBrowser.Controller.Entities
.Where(e => query is null || UserViewBuilder.FilterItem(e, query))
.ToArray();
- if (this is BoxSet && (query.OrderBy is null || query.OrderBy.Count == 0))
- {
- realChildren = realChildren
- .OrderBy(e => e.PremiereDate ?? DateTime.MaxValue)
- .ToArray();
- }
-
var childCount = realChildren.Length;
if (result.Count < limit)
{
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index b32b64f5d..ecbeefbb9 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -10,6 +10,7 @@ using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Entities
{
@@ -125,6 +126,8 @@ namespace MediaBrowser.Controller.Entities
public string? Name { get; set; }
+ public bool? UseRawName { get; set; }
+
public string? Person { get; set; }
public Guid[] PersonIds { get; set; }
@@ -386,5 +389,75 @@ namespace MediaBrowser.Controller.Entities
User = user;
}
+
+ public void ApplyFilters(ItemFilter[] filters)
+ {
+ static void ThrowConflictingFilters()
+ => throw new ArgumentException("Conflicting filters", nameof(filters));
+
+ foreach (var filter in filters)
+ {
+ switch (filter)
+ {
+ case ItemFilter.IsFolder:
+ if (filters.Contains(ItemFilter.IsNotFolder))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsFolder = true;
+ break;
+ case ItemFilter.IsNotFolder:
+ if (filters.Contains(ItemFilter.IsFolder))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsFolder = false;
+ break;
+ case ItemFilter.IsUnplayed:
+ if (filters.Contains(ItemFilter.IsPlayed))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsPlayed = false;
+ break;
+ case ItemFilter.IsPlayed:
+ if (filters.Contains(ItemFilter.IsUnplayed))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsPlayed = true;
+ break;
+ case ItemFilter.IsFavorite:
+ IsFavorite = true;
+ break;
+ case ItemFilter.IsResumable:
+ IsResumable = true;
+ break;
+ case ItemFilter.Likes:
+ if (filters.Contains(ItemFilter.Dislikes))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsLiked = true;
+ break;
+ case ItemFilter.Dislikes:
+ if (filters.Contains(ItemFilter.Likes))
+ {
+ ThrowConflictingFilters();
+ }
+
+ IsLiked = false;
+ break;
+ case ItemFilter.IsFavoriteOrLikes:
+ IsFavoriteOrLiked = true;
+ break;
+ }
+ }
+ }
}
}
diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
index 1d1fb2c39..3999c3e07 100644
--- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
+++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
@@ -124,7 +124,7 @@ namespace MediaBrowser.Controller.Entities.Movies
if (sortBy == ItemSortBy.Default)
{
- return items;
+ return items;
}
return LibraryManager.Sort(items, user, new[] { sortBy }, SortOrder.Ascending);
@@ -136,6 +136,12 @@ namespace MediaBrowser.Controller.Entities.Movies
return Sort(children, user).ToArray();
}
+ public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount, InternalItemsQuery query = null)
+ {
+ var children = base.GetChildren(user, includeLinkedChildren, out totalItemCount, query);
+ return Sort(children, user).ToArray();
+ }
+
public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
{
var children = base.GetRecursiveChildren(user, query, out totalCount);
diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs
index b972ebaa6..4360253b0 100644
--- a/MediaBrowser.Controller/Entities/TV/Season.cs
+++ b/MediaBrowser.Controller/Entities/TV/Season.cs
@@ -201,12 +201,17 @@ namespace MediaBrowser.Controller.Entities.TV
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
+ if (series is null)
+ {
+ return [];
+ }
+
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
}
public List<BaseItem> GetEpisodes()
{
- return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
+ return GetEpisodes(Series, null, null, new DtoOptions(true), true);
}
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index 427c2995b..6a26ecaeb 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey;
query.IncludeItemTypes = new[] { BaseItemKind.Season };
- query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) };
+ query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
if (user is not null && !user.DisplayMissingEpisodes)
{
@@ -247,6 +247,10 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey;
+ if (query.OrderBy.Count == 0)
+ {
+ query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
+ }
if (query.IncludeItemTypes.Length == 0)
{
@@ -447,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/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index fcc5ed672..df1c98f3f 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -282,6 +282,14 @@ namespace MediaBrowser.Controller.Library
Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
/// <summary>
+ /// Reattaches the user data to the item.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A task that represents the asynchronous reattachment operation.</returns>
+ Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
+
+ /// <summary>
/// Retrieves the item.
/// </summary>
/// <param name="id">The id.</param>
@@ -652,5 +660,12 @@ namespace MediaBrowser.Controller.Library
/// This exists so plugins can trigger a library scan.
/// </remarks>
void QueueLibraryScan();
+
+ /// <summary>
+ /// Add mblink file for a media path.
+ /// </summary>
+ /// <param name="virtualFolderPath">The path to the virtualfolder.</param>
+ /// <param name="pathInfo">The new virtualfolder.</param>
+ public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo);
}
}
diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
index 0de5f198d..6da398129 100644
--- a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
+++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
+using System.Threading.Channels;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using Microsoft.Extensions.Hosting;
@@ -29,7 +30,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
/// </summary>
private readonly Lock _taskLock = new();
- private readonly BlockingCollection<TaskQueueItem> _tasks = new();
+ private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
private volatile int _workCounter;
private Task? _cleanupTask;
@@ -77,7 +78,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
lock (_taskLock)
{
- if (_tasks.Count > 0 || _workCounter > 0)
+ if (_tasks.Reader.Count > 0 || _workCounter > 0)
{
_logger.LogDebug("Delay cleanup task, operations still running.");
// tasks are still there so its still in use. Reschedule cleanup task.
@@ -144,9 +145,9 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
_deadlockDetector.Value = stopToken.TaskStop;
try
{
- foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token))
+ while (!stopToken.GlobalStop.Token.IsCancellationRequested)
{
- stopToken.GlobalStop.Token.ThrowIfCancellationRequested();
+ var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
try
{
var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
@@ -187,7 +188,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
await item.Worker(item.Data).ConfigureAwait(true);
}
- catch (System.Exception ex)
+ catch (Exception ex)
{
_logger.LogError(ex, "Error while performing a library operation");
}
@@ -242,7 +243,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
};
}).ToArray();
- if (ShouldForceSequentialOperation())
+ if (ShouldForceSequentialOperation() || _deadlockDetector.Value is not null)
{
_logger.LogDebug("Process sequentially.");
try
@@ -264,35 +265,14 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
for (var i = 0; i < workItems.Length; i++)
{
var item = workItems[i]!;
- _tasks.Add(item, CancellationToken.None);
+ await _tasks.Writer.WriteAsync(item, CancellationToken.None).ConfigureAwait(false);
}
- if (_deadlockDetector.Value is not null)
- {
- _logger.LogDebug("Nested invocation detected, process in-place.");
- try
- {
- // we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved
- while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token))
- {
- await ProcessItem(item).ConfigureAwait(false);
- }
- }
- catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested)
- {
- // operation is cancelled. Do nothing.
- }
-
- _logger.LogDebug("process in-place done.");
- }
- else
- {
- Worker();
- _logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
- await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
- _logger.LogDebug("{NoWorkers} completed.", workItems.Length);
- ScheduleTaskCleanup();
- }
+ Worker();
+ _logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
+ await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
+ _logger.LogDebug("{NoWorkers} completed.", workItems.Length);
+ ScheduleTaskCleanup();
}
/// <inheritdoc/>
@@ -304,13 +284,12 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
}
_disposed = true;
- _tasks.CompleteAdding();
+ _tasks.Writer.Complete();
foreach (var item in _taskRunners)
{
await item.Key.CancelAsync().ConfigureAwait(false);
}
- _tasks.Dispose();
if (_cleanupTask is not null)
{
await _cleanupTask.ConfigureAwait(false);
diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
index b5d14e94b..0025080cc 100644
--- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj
+++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
@@ -19,9 +19,7 @@
<ItemGroup>
<PackageReference Include="BitFaster.Caching" />
- <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
- <PackageReference Include="System.Threading.Tasks.Dataflow" />
</ItemGroup>
<ItemGroup>
@@ -36,7 +34,7 @@
</ItemGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
index 20f51ddb7..10f2f04af 100644
--- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
+++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
@@ -43,8 +43,6 @@ namespace MediaBrowser.Controller.MediaEncoding
public bool AllowAudioStreamCopy { get; set; }
- public bool BreakOnNonKeyFrames { get; set; }
-
/// <summary>
/// Gets or sets the audio sample rate.
/// </summary>
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 843590a1f..c7b11f47d 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -1267,6 +1267,20 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
+ // Use analyzeduration also for subtitle streams to improve resolution detection with streams inside MKS files
+ var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
+ if (!string.IsNullOrEmpty(analyzeDurationArgument))
+ {
+ arg.Append(' ').Append(analyzeDurationArgument);
+ }
+
+ // Apply probesize, too, if configured
+ var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
+ if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
+ {
+ arg.Append(' ').Append(ffmpegProbeSizeArgument);
+ }
+
// Also seek the external subtitles stream.
var seekSubParam = GetFastSeekCommandLineParameter(state, options, segmentContainer);
if (!string.IsNullOrEmpty(seekSubParam))
@@ -2914,8 +2928,8 @@ namespace MediaBrowser.Controller.MediaEncoding
if (time > 0)
{
- // For direct streaming/remuxing, we seek at the exact position of the keyframe
- // However, ffmpeg will seek to previous keyframe when the exact time is the input
+ // For direct streaming/remuxing, HLS segments start at keyframes.
+ // However, ffmpeg will seek to previous keyframe when the exact frame time is the input
// Workaround this by adding 0.5s offset to the seeking time to get the exact keyframe on most videos.
// This will help subtitle syncing.
var isHlsRemuxing = state.IsVideoRequest && state.TranscodingType is TranscodingJobType.Hls && IsCopyCodec(state.OutputVideoCodec);
@@ -2932,17 +2946,16 @@ namespace MediaBrowser.Controller.MediaEncoding
if (state.IsVideoRequest)
{
- var outputVideoCodec = GetVideoEncoder(state, options);
- var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.');
-
- // Important: If this is ever re-enabled, make sure not to use it with wtv because it breaks seeking
- // Disable -noaccurate_seek on mpegts container due to the timestamps issue on some clients,
- // but it's still required for fMP4 container otherwise the audio can't be synced to the video.
- if (!string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase)
- && state.TranscodingType != TranscodingJobType.Progressive
- && !state.EnableBreakOnNonKeyFrames(outputVideoCodec)
- && (state.BaseRequest.StartTimeTicks ?? 0) > 0)
+ // If we are remuxing, then the copied stream cannot be seeked accurately (it will seek to the nearest
+ // keyframe). If we are using fMP4, then force all other streams to use the same inaccurate seeking to
+ // avoid A/V sync issues which cause playback issues on some devices.
+ // When remuxing video, the segment start times correspond to key frames in the source stream, so this
+ // option shouldn't change the seeked point that much.
+ // Important: make sure not to use it with wtv because it breaks seeking
+ if (state.TranscodingType is TranscodingJobType.Hls
+ && string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase)
+ && (IsCopyCodec(state.OutputVideoCodec) || IsCopyCodec(state.OutputAudioCodec))
+ && !string.Equals(state.InputContainer, "wtv", StringComparison.OrdinalIgnoreCase))
{
seekParam += " -noaccurate_seek";
}
@@ -6359,6 +6372,21 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
+ // Block unsupported H.264 Hi422P and Hi444PP profiles, which can be encoded with 4:2:0 pixel format
+ if (string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ if (videoStream.Profile.Contains("4:2:2", StringComparison.OrdinalIgnoreCase)
+ || videoStream.Profile.Contains("4:4:4", StringComparison.OrdinalIgnoreCase))
+ {
+ // VideoToolbox on Apple Silicon has H.264 Hi444PP and theoretically also has Hi422P
+ if (!(hardwareAccelerationType == HardwareAccelerationType.videotoolbox
+ && RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64)))
+ {
+ return null;
+ }
+ }
+ }
+
var decoder = hardwareAccelerationType switch
{
HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth),
@@ -7039,8 +7067,8 @@ namespace MediaBrowser.Controller.MediaEncoding
if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))
{
- var accelType = GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
- return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty);
+ // there's an issue about AV1 AFBC on RK3588, disable it for now until it's fixed upstream
+ return GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
}
}
@@ -7069,7 +7097,7 @@ namespace MediaBrowser.Controller.MediaEncoding
}
#nullable disable
- public void TryStreamCopy(EncodingJobInfo state)
+ public void TryStreamCopy(EncodingJobInfo state, EncodingOptions options)
{
if (state.VideoStream is not null && CanStreamCopyVideo(state, state.VideoStream))
{
@@ -7086,8 +7114,14 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
+ var preventHlsAudioCopy = state.TranscodingType is TranscodingJobType.Hls
+ && state.VideoStream is not null
+ && !IsCopyCodec(state.OutputVideoCodec)
+ && options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio;
+
if (state.AudioStream is not null
- && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs))
+ && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs)
+ && !preventHlsAudioCopy)
{
state.OutputAudioCodec = "copy";
}
@@ -7103,9 +7137,8 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
- public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
+ private string GetFfmpegAnalyzeDurationArg(EncodingJobInfo state)
{
- var inputModifier = string.Empty;
var analyzeDurationArgument = string.Empty;
// Apply -analyzeduration as per the environment variable,
@@ -7121,6 +7154,26 @@ namespace MediaBrowser.Controller.MediaEncoding
analyzeDurationArgument = "-analyzeduration " + ffmpegAnalyzeDuration;
}
+ return analyzeDurationArgument;
+ }
+
+ private string GetFfmpegProbesizeArg()
+ {
+ var ffmpegProbeSize = _config.GetFFmpegProbeSize();
+
+ if (!string.IsNullOrEmpty(ffmpegProbeSize))
+ {
+ return $"-probesize {ffmpegProbeSize}";
+ }
+
+ return string.Empty;
+ }
+
+ public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
+ {
+ var inputModifier = string.Empty;
+ var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
+
if (!string.IsNullOrEmpty(analyzeDurationArgument))
{
inputModifier += " " + analyzeDurationArgument;
@@ -7129,11 +7182,11 @@ namespace MediaBrowser.Controller.MediaEncoding
inputModifier = inputModifier.Trim();
// Apply -probesize if configured
- var ffmpegProbeSize = _config.GetFFmpegProbeSize();
+ var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
- if (!string.IsNullOrEmpty(ffmpegProbeSize))
+ if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
{
- inputModifier += $" -probesize {ffmpegProbeSize}";
+ inputModifier += " " + ffmpegProbeSizeArgument;
}
var userAgentParam = GetUserAgentParam(state);
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index 43680f5c0..7d0384ef2 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -515,21 +515,6 @@ namespace MediaBrowser.Controller.MediaEncoding
public int HlsListSize => 0;
- public bool EnableBreakOnNonKeyFrames(string videoCodec)
- {
- if (TranscodingType != TranscodingJobType.Progressive)
- {
- if (IsSegmentedLiveStream)
- {
- return false;
- }
-
- return BaseRequest.BreakOnNonKeyFrames && EncodingHelper.IsCopyCodec(videoCodec);
- }
-
- return false;
- }
-
private int? GetMediaStreamCount(MediaStreamType type, int limit)
{
var count = MediaSource.GetStreamCount(type);
diff --git a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs
index 3d288b9f8..2702e3bc0 100644
--- a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs
+++ b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs
@@ -27,10 +27,9 @@ namespace MediaBrowser.Controller.MediaEncoding
using (target)
using (reader)
{
- while (!reader.EndOfStream && reader.BaseStream.CanRead)
+ string line = await reader.ReadLineAsync().ConfigureAwait(false);
+ while (line is not null && reader.BaseStream.CanRead)
{
- var line = await reader.ReadLineAsync().ConfigureAwait(false);
-
ParseLogLine(line, state);
var bytes = Encoding.UTF8.GetBytes(Environment.NewLine + line);
@@ -50,6 +49,7 @@ namespace MediaBrowser.Controller.MediaEncoding
}
await target.FlushAsync().ConfigureAwait(false);
+ line = await reader.ReadLineAsync().ConfigureAwait(false);
}
}
}
diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs
index 00c492742..bf80b7d0a 100644
--- a/MediaBrowser.Controller/Persistence/IItemRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs
@@ -36,6 +36,14 @@ public interface IItemRepository
Task SaveImagesAsync(BaseItem item, CancellationToken cancellationToken = default);
/// <summary>
+ /// Reattaches the user data to the item.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A task that represents the asynchronous reattachment operation.</returns>
+ Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
+
+ /// <summary>
/// Retrieves the item.
/// </summary>
/// <param name="id">The id.</param>
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.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs
index 2b3afa117..c11c65c33 100644
--- a/MediaBrowser.Controller/Session/ISessionManager.cs
+++ b/MediaBrowser.Controller/Session/ISessionManager.cs
@@ -350,5 +350,12 @@ namespace MediaBrowser.Controller.Session
/// <param name="sessionIdOrPlaySessionId">The session id or playsession id.</param>
/// <returns>Task.</returns>
Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId);
+
+ /// <summary>
+ /// Gets the dto for session info.
+ /// </summary>
+ /// <param name="sessionInfo">The session info.</param>
+ /// <returns><see cref="SessionInfoDto"/> of the session.</returns>
+ SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo);
}
}
diff --git a/MediaBrowser.Controller/Sorting/SortExtensions.cs b/MediaBrowser.Controller/Sorting/SortExtensions.cs
index f9c0d39dd..ec8878dcb 100644
--- a/MediaBrowser.Controller/Sorting/SortExtensions.cs
+++ b/MediaBrowser.Controller/Sorting/SortExtensions.cs
@@ -1,7 +1,9 @@
#pragma warning disable CS1591
using System;
+using System.Collections;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using Jellyfin.Extensions;
@@ -9,7 +11,7 @@ namespace MediaBrowser.Controller.Sorting
{
public static class SortExtensions
{
- private static readonly AlphanumericComparator _comparer = new AlphanumericComparator();
+ private static readonly StringComparer _comparer = StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
public static IEnumerable<T> OrderByString<T>(this IEnumerable<T> list, Func<T, string> getName)
{
diff --git a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj
index 8e3c8cf7f..c3c26085c 100644
--- a/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj
+++ b/MediaBrowser.LocalMetadata/MediaBrowser.LocalMetadata.csproj
@@ -11,7 +11,7 @@
</ItemGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index f4e8c39c1..68d6d215b 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -693,7 +693,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
[GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
private static partial Regex CodecRegex();
- [GeneratedRegex("^\\s\\S{3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
+ [GeneratedRegex("^\\s\\S{2,3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
private static partial Regex FilterRegex();
}
}
diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
index be7eeda92..fc11047a7 100644
--- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
+++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
@@ -6,7 +6,7 @@
</PropertyGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
@@ -26,7 +26,6 @@
<PackageReference Include="BDInfo" />
<PackageReference Include="libse" />
<PackageReference Include="Microsoft.Extensions.Http" />
- <PackageReference Include="System.Text.Encoding.CodePages" />
<PackageReference Include="UTF.Unknown" />
</ItemGroup>
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 1823496bf..127bdd380 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>
@@ -300,9 +301,12 @@ namespace MediaBrowser.MediaEncoding.Probing
// Handle WebM
else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase))
{
- // Limit WebM to supported codecs
- if (mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
- || (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
+ // Limit WebM to supported stream types and codecs.
+ // FFprobe can report "matroska,webm" for Matroska-like containers, so only keep "webm" if all streams are WebM-compatible.
+ // Any stream that is not video nor audio is not supported in WebM and should disqualify the webm container probe result.
+ if (mediaStreams.Any(stream => stream.Type is not MediaStreamType.Video and not MediaStreamType.Audio)
+ || mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
+ || (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
{
splitFormat[i] = string.Empty;
}
@@ -854,7 +858,12 @@ namespace MediaBrowser.MediaEncoding.Probing
}
// http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe
- if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
+ if (string.IsNullOrEmpty(streamInfo.SampleAspectRatio)
+ && string.IsNullOrEmpty(streamInfo.DisplayAspectRatio))
+ {
+ stream.IsAnamorphic = false;
+ }
+ else if (IsNearSquarePixelSar(streamInfo.SampleAspectRatio))
{
stream.IsAnamorphic = false;
}
@@ -1146,6 +1155,34 @@ namespace MediaBrowser.MediaEncoding.Probing
}
/// <summary>
+ /// Determines whether a sample aspect ratio represents square (or near-square) pixels.
+ /// Some encoders produce SARs like 3201:3200 for content that is effectively 1:1,
+ /// which would be falsely classified as anamorphic by an exact string comparison.
+ /// A 1% tolerance safely covers encoder rounding artifacts while preserving detection
+ /// of genuine anamorphic content (closest standard is PAL 4:3 at 16:15 = 6.67% off).
+ /// </summary>
+ /// <param name="sar">The sample aspect ratio string in "N:D" format.</param>
+ /// <returns><c>true</c> if the SAR is within 1% of 1:1; otherwise <c>false</c>.</returns>
+ internal static bool IsNearSquarePixelSar(string sar)
+ {
+ if (string.IsNullOrEmpty(sar))
+ {
+ return false;
+ }
+
+ var parts = sar.Split(':');
+ if (parts.Length == 2
+ && double.TryParse(parts[0], CultureInfo.InvariantCulture, out var num)
+ && double.TryParse(parts[1], CultureInfo.InvariantCulture, out var den)
+ && den > 0)
+ {
+ return IsClose(num / den, 1.0, 0.01);
+ }
+
+ return string.Equals(sar, "1:1", StringComparison.Ordinal);
+ }
+
+ /// <summary>
/// Gets a frame rate from a string value in ffprobe output
/// This could be a number or in the format of 2997/125.
/// </summary>
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index bf7ec05a9..aeaf7f442 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -328,7 +328,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{
- if (!File.Exists(outputPath))
+ if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
}
@@ -431,9 +431,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
}
- else if (!File.Exists(outputPath))
+ else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{
failed = true;
+
+ try
+ {
+ _logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
+ _fileSystem.DeleteFile(outputPath);
+ }
+ catch (FileNotFoundException)
+ {
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
+ }
}
if (failed)
@@ -507,7 +520,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
- if (File.Exists(outputPath))
+ if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
{
releaser.Dispose();
continue;
@@ -722,10 +735,24 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
foreach (var outputPath in outputPaths)
{
- if (!File.Exists(outputPath))
+ if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
failed = true;
+
+ try
+ {
+ _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
+ _fileSystem.DeleteFile(outputPath);
+ }
+ catch (FileNotFoundException)
+ {
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
+ }
+
continue;
}
@@ -764,7 +791,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{
- if (!File.Exists(outputPath))
+ if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{
var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
@@ -867,9 +894,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
}
}
- else if (!File.Exists(outputPath))
+ else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{
failed = true;
+
+ try
+ {
+ _logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
+ _fileSystem.DeleteFile(outputPath);
+ }
+ catch (FileNotFoundException)
+ {
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
+ }
}
if (failed)
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index 2fd054f11..defd855ec 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -673,7 +673,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
if (state.VideoRequest is not null)
{
- _encodingHelper.TryStreamCopy(state);
+ _encodingHelper.TryStreamCopy(state, encodingOptions);
}
}
diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs
index f7f386d28..98fc2e632 100644
--- a/MediaBrowser.Model/Configuration/EncodingOptions.cs
+++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs
@@ -1,6 +1,7 @@
#pragma warning disable CA1819 // XML serialization handles collections improperly, so we need to use arrays
#nullable disable
+using System.ComponentModel;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Model.Configuration;
@@ -60,6 +61,7 @@ public class EncodingOptions
SubtitleExtractionTimeoutMinutes = 30;
AllowOnDemandMetadataBasedKeyframeExtractionForExtensions = ["mkv"];
HardwareDecodingCodecs = ["h264", "vc1"];
+ HlsAudioSeekStrategy = HlsAudioSeekStrategy.DisableAccurateSeek;
}
/// <summary>
@@ -301,4 +303,10 @@ public class EncodingOptions
/// Gets or sets the file extensions on-demand metadata based keyframe extraction is enabled for.
/// </summary>
public string[] AllowOnDemandMetadataBasedKeyframeExtractionForExtensions { get; set; }
+
+ /// <summary>
+ /// Gets or sets the method used for audio seeking in HLS.
+ /// </summary>
+ [DefaultValue(HlsAudioSeekStrategy.DisableAccurateSeek)]
+ public HlsAudioSeekStrategy HlsAudioSeekStrategy { get; set; }
}
diff --git a/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs
new file mode 100644
index 000000000..49feeb435
--- /dev/null
+++ b/MediaBrowser.Model/Configuration/HlsAudioSeekStrategy.cs
@@ -0,0 +1,23 @@
+namespace MediaBrowser.Model.Configuration
+{
+ /// <summary>
+ /// An enum representing the options to seek the input audio stream when
+ /// transcoding HLS segments.
+ /// </summary>
+ public enum HlsAudioSeekStrategy
+ {
+ /// <summary>
+ /// If the video stream is transcoded and the audio stream is copied,
+ /// seek the video stream to the same keyframe as the audio stream. The
+ /// resulting timestamps in the output streams may be inaccurate.
+ /// </summary>
+ DisableAccurateSeek = 0,
+
+ /// <summary>
+ /// Prevent audio streams from being copied if the video stream is transcoded.
+ /// The resulting timestamps will be accurate, but additional audio transcoding
+ /// overhead will be incurred.
+ /// </summary>
+ TranscodeAudio = 1,
+ }
+}
diff --git a/MediaBrowser.Model/Dlna/ConditionProcessor.cs b/MediaBrowser.Model/Dlna/ConditionProcessor.cs
index 1b61bfe15..79ee683a2 100644
--- a/MediaBrowser.Model/Dlna/ConditionProcessor.cs
+++ b/MediaBrowser.Model/Dlna/ConditionProcessor.cs
@@ -324,7 +324,7 @@ namespace MediaBrowser.Model.Dlna
return !condition.IsRequired;
}
- var expected = (TransportStreamTimestamp)Enum.Parse(typeof(TransportStreamTimestamp), condition.Value, true);
+ var expected = Enum.Parse<TransportStreamTimestamp>(condition.Value, true);
switch (condition.Condition)
{
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index 61e04a813..75b8c137f 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -610,7 +610,6 @@ namespace MediaBrowser.Model.Dlna
playlistItem.EnableSubtitlesInManifest = transcodingProfile.EnableSubtitlesInManifest;
playlistItem.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode;
- playlistItem.BreakOnNonKeyFrames = transcodingProfile.BreakOnNonKeyFrames;
playlistItem.EnableAudioVbrEncoding = transcodingProfile.EnableAudioVbrEncoding;
if (transcodingProfile.MinSegments > 0)
@@ -2010,7 +2009,7 @@ namespace MediaBrowser.Model.Dlna
}
else if (condition.Condition == ProfileConditionType.NotEquals)
{
- item.SetOption(qualifier, "rangetype", string.Join(',', Enum.GetNames(typeof(VideoRangeType)).Except(values)));
+ item.SetOption(qualifier, "rangetype", string.Join(',', Enum.GetNames<VideoRangeType>().Except(values)));
}
else if (condition.Condition == ProfileConditionType.EqualsAny)
{
diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs
index 3efb143bc..7aad97ce0 100644
--- a/MediaBrowser.Model/Dlna/StreamInfo.cs
+++ b/MediaBrowser.Model/Dlna/StreamInfo.cs
@@ -87,11 +87,6 @@ public class StreamInfo
public int? MinSegments { get; set; }
/// <summary>
- /// Gets or sets a value indicating whether the stream can be broken on non-keyframes.
- /// </summary>
- public bool BreakOnNonKeyFrames { get; set; }
-
- /// <summary>
/// Gets or sets a value indicating whether the stream requires AVC.
/// </summary>
public bool RequireAvc { get; set; }
@@ -900,7 +895,7 @@ public class StreamInfo
if (SubProtocol == MediaStreamProtocol.hls)
{
- sb.Append("/master.m3u8?");
+ sb.Append("/master.m3u8");
}
else
{
@@ -911,10 +906,10 @@ public class StreamInfo
sb.Append('.');
sb.Append(Container);
}
-
- sb.Append('?');
}
+ var queryStart = sb.Length;
+
if (!string.IsNullOrEmpty(DeviceProfileId))
{
sb.Append("&DeviceProfileId=");
@@ -1018,9 +1013,6 @@ public class StreamInfo
sb.Append("&MinSegments=");
sb.Append(MinSegments.Value.ToString(CultureInfo.InvariantCulture));
}
-
- sb.Append("&BreakOnNonKeyFrames=");
- sb.Append(BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture));
}
else
{
@@ -1141,6 +1133,12 @@ public class StreamInfo
sb.Append(query);
}
+ // Replace the first '&' with '?' to form a valid query string.
+ if (sb.Length > queryStart)
+ {
+ sb[queryStart] = '?';
+ }
+
return sb.ToString();
}
diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
index 5797d4250..f49b24976 100644
--- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs
+++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
@@ -41,7 +41,6 @@ public class TranscodingProfile
MaxAudioChannels = other.MaxAudioChannels;
MinSegments = other.MinSegments;
SegmentLength = other.SegmentLength;
- BreakOnNonKeyFrames = other.BreakOnNonKeyFrames;
Conditions = other.Conditions;
EnableAudioVbrEncoding = other.EnableAudioVbrEncoding;
}
@@ -143,7 +142,8 @@ public class TranscodingProfile
/// </summary>
[DefaultValue(false)]
[XmlAttribute("breakOnNonKeyFrames")]
- public bool BreakOnNonKeyFrames { get; set; }
+ [Obsolete("This is always false")]
+ public bool? BreakOnNonKeyFrames { get; set; }
/// <summary>
/// Gets or sets the profile conditions.
diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs
index b1626e2c9..c443af32c 100644
--- a/MediaBrowser.Model/Entities/MediaStream.cs
+++ b/MediaBrowser.Model/Entities/MediaStream.cs
@@ -260,6 +260,8 @@ namespace MediaBrowser.Model.Entities
public string LocalizedHearingImpaired { get; set; }
+ public string LocalizedLanguage { get; set; }
+
public string DisplayTitle
{
get
@@ -273,29 +275,8 @@ namespace MediaBrowser.Model.Entities
// Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded).
if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase))
{
- // Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified).
- var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
- CultureInfo match = null;
- if (Language.Contains('-', StringComparison.OrdinalIgnoreCase))
- {
- match = cultures.FirstOrDefault(r =>
- r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase));
-
- if (match is null)
- {
- string baseLang = Language.AsSpan().LeftPart('-').ToString();
- match = cultures.FirstOrDefault(r =>
- r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase));
- }
- }
- else
- {
- match = cultures.FirstOrDefault(r =>
- r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase));
- }
-
- string fullLanguage = match?.DisplayName;
- attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language));
+ // Use pre-resolved localized language name, falling back to raw language code.
+ attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
}
if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase))
@@ -393,29 +374,8 @@ namespace MediaBrowser.Model.Entities
if (!string.IsNullOrEmpty(Language))
{
- // Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified).
- var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
- CultureInfo match = null;
- if (Language.Contains('-', StringComparison.OrdinalIgnoreCase))
- {
- match = cultures.FirstOrDefault(r =>
- r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase));
-
- if (match is null)
- {
- string baseLang = Language.AsSpan().LeftPart('-').ToString();
- match = cultures.FirstOrDefault(r =>
- r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase));
- }
- }
- else
- {
- match = cultures.FirstOrDefault(r =>
- r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase));
- }
-
- string fullLanguage = match?.DisplayName;
- attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language));
+ // Use pre-resolved localized language name, falling back to raw language code.
+ attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
}
else
{
diff --git a/MediaBrowser.Model/Extensions/EnumerableExtensions.cs b/MediaBrowser.Model/Extensions/EnumerableExtensions.cs
index 94f425229..7c9ee18ca 100644
--- a/MediaBrowser.Model/Extensions/EnumerableExtensions.cs
+++ b/MediaBrowser.Model/Extensions/EnumerableExtensions.cs
@@ -11,7 +11,7 @@ namespace MediaBrowser.Model.Extensions
public static class EnumerableExtensions
{
/// <summary>
- /// Orders <see cref="RemoteImageInfo"/> by requested language in descending order, prioritizing "en" over other non-matches.
+ /// Orders <see cref="RemoteImageInfo"/> by requested language in descending order, then "en", then no language, over other non-matches.
/// </summary>
/// <param name="remoteImageInfos">The remote image infos.</param>
/// <param name="requestedLanguage">The requested language for the images.</param>
@@ -28,9 +28,9 @@ namespace MediaBrowser.Model.Extensions
{
// Image priority ordering:
// - Images that match the requested language
- // - Images with no language
// - TODO: Images that match the original language
// - Images in English
+ // - Images with no language
// - Images that don't match the requested language
if (string.Equals(requestedLanguage, i.Language, StringComparison.OrdinalIgnoreCase))
@@ -38,12 +38,12 @@ namespace MediaBrowser.Model.Extensions
return 4;
}
- if (string.IsNullOrEmpty(i.Language))
+ if (string.Equals(i.Language, "en", StringComparison.OrdinalIgnoreCase))
{
return 3;
}
- if (string.Equals(i.Language, "en", StringComparison.OrdinalIgnoreCase))
+ if (string.IsNullOrEmpty(i.Language))
{
return 2;
}
diff --git a/MediaBrowser.Model/Extensions/StringHelper.cs b/MediaBrowser.Model/Extensions/StringHelper.cs
index 77cbef00f..58cde8620 100644
--- a/MediaBrowser.Model/Extensions/StringHelper.cs
+++ b/MediaBrowser.Model/Extensions/StringHelper.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace MediaBrowser.Model.Extensions
{
/// <summary>
@@ -25,14 +27,11 @@ namespace MediaBrowser.Model.Extensions
return string.Create(
str.Length,
- str,
+ str.AsSpan(),
(chars, buf) =>
{
chars[0] = char.ToUpperInvariant(buf[0]);
- for (int i = 1; i < chars.Length; i++)
- {
- chars[i] = buf[i];
- }
+ buf.Slice(1).CopyTo(chars.Slice(1));
});
}
}
diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj
index ef025d02d..c655c4ccb 100644
--- a/MediaBrowser.Model/MediaBrowser.Model.csproj
+++ b/MediaBrowser.Model/MediaBrowser.Model.csproj
@@ -14,7 +14,7 @@
</PropertyGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
@@ -37,13 +37,10 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="MimeTypes">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
- <PackageReference Include="System.Globalization" />
- <PackageReference Include="System.Text.Json" />
</ItemGroup>
<ItemGroup>
diff --git a/MediaBrowser.Model/Net/IPData.cs b/MediaBrowser.Model/Net/IPData.cs
index c116d883e..e016ffea1 100644
--- a/MediaBrowser.Model/Net/IPData.cs
+++ b/MediaBrowser.Model/Net/IPData.cs
@@ -1,6 +1,5 @@
using System.Net;
using System.Net.Sockets;
-using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
namespace MediaBrowser.Model.Net;
@@ -66,9 +65,9 @@ public class IPData
{
if (Address.Equals(IPAddress.None))
{
- return Subnet.Prefix.AddressFamily.Equals(IPAddress.None)
+ return Subnet.BaseAddress.AddressFamily.Equals(IPAddress.None)
? AddressFamily.Unspecified
- : Subnet.Prefix.AddressFamily;
+ : Subnet.BaseAddress.AddressFamily;
}
else
{
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.Model/Session/ClientCapabilities.cs b/MediaBrowser.Model/Session/ClientCapabilities.cs
index fc1f24ae1..597845fc1 100644
--- a/MediaBrowser.Model/Session/ClientCapabilities.cs
+++ b/MediaBrowser.Model/Session/ClientCapabilities.cs
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
-using System.ComponentModel;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Dlna;
@@ -31,15 +30,5 @@ namespace MediaBrowser.Model.Session
public string AppStoreUrl { get; set; }
public string IconUrl { get; set; }
-
- // TODO: Remove after 10.9
- [Obsolete("Unused")]
- [DefaultValue(false)]
- public bool? SupportsContentUploading { get; set; } = false;
-
- // TODO: Remove after 10.9
- [Obsolete("Unused")]
- [DefaultValue(false)]
- public bool? SupportsSync { get; set; } = false;
}
}
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs
new file mode 100644
index 000000000..69cae7762
--- /dev/null
+++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubImageProvider.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.OpenPackagingFormat
+{
+ /// <summary>
+ /// Provides the primary image for EPUB items that have embedded covers.
+ /// </summary>
+ public class EpubImageProvider : IDynamicImageProvider
+ {
+ private readonly ILogger<EpubImageProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="EpubImageProvider"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{EpubImageProvider}"/> interface.</param>
+ public EpubImageProvider(ILogger<EpubImageProvider> logger)
+ {
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public string Name => "EPUB Metadata";
+
+ /// <inheritdoc />
+ public bool Supports(BaseItem item)
+ {
+ return item is Book;
+ }
+
+ /// <inheritdoc />
+ public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
+ {
+ yield return ImageType.Primary;
+ }
+
+ /// <inheritdoc />
+ public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
+ {
+ if (string.Equals(Path.GetExtension(item.Path), ".epub", StringComparison.OrdinalIgnoreCase))
+ {
+ return GetFromZip(item, cancellationToken);
+ }
+
+ return Task.FromResult(new DynamicImageResponse { HasImage = false });
+ }
+
+ private async Task<DynamicImageResponse> LoadCover(ZipArchive epub, XmlDocument opf, string opfRootDirectory, CancellationToken cancellationToken)
+ {
+ var utilities = new OpfReader<EpubImageProvider>(opf, _logger);
+ var coverReference = utilities.ReadCoverPath(opfRootDirectory);
+ if (coverReference == null)
+ {
+ return new DynamicImageResponse { HasImage = false };
+ }
+
+ var cover = coverReference.Value;
+ var coverFile = epub.GetEntry(cover.Path);
+
+ if (coverFile == null)
+ {
+ return new DynamicImageResponse { HasImage = false };
+ }
+
+ var memoryStream = new MemoryStream();
+
+ var coverStream = await coverFile.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using (coverStream.ConfigureAwait(false))
+ {
+ await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
+ }
+
+ memoryStream.Position = 0;
+
+ var response = new DynamicImageResponse { HasImage = true, Stream = memoryStream };
+ response.SetFormatFromMimeType(cover.MimeType);
+
+ return response;
+ }
+
+ private async Task<DynamicImageResponse> GetFromZip(BaseItem item, CancellationToken cancellationToken)
+ {
+ using var epub = await ZipFile.OpenReadAsync(item.Path, cancellationToken).ConfigureAwait(false);
+
+ var opfFilePath = EpubUtils.ReadContentFilePath(epub);
+ if (opfFilePath == null)
+ {
+ return new DynamicImageResponse { HasImage = false };
+ }
+
+ var opfRootDirectory = Path.GetDirectoryName(opfFilePath);
+ if (opfRootDirectory == null)
+ {
+ return new DynamicImageResponse { HasImage = false };
+ }
+
+ var opfFile = epub.GetEntry(opfFilePath);
+ if (opfFile == null)
+ {
+ return new DynamicImageResponse { HasImage = false };
+ }
+
+ using var opfStream = await opfFile.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ var opfDocument = new XmlDocument();
+ opfDocument.Load(opfStream);
+
+ return await LoadCover(epub, opfDocument, opfRootDirectory, cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs
new file mode 100644
index 000000000..bc77e5928
--- /dev/null
+++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubProvider.cs
@@ -0,0 +1,100 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.OpenPackagingFormat
+{
+ /// <summary>
+ /// Provides book metadata from OPF content in an EPUB item.
+ /// </summary>
+ public class EpubProvider : ILocalMetadataProvider<Book>
+ {
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger<EpubProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="EpubProvider"/> class.
+ /// </summary>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{EpubProvider}"/> interface.</param>
+ public EpubProvider(IFileSystem fileSystem, ILogger<EpubProvider> logger)
+ {
+ _fileSystem = fileSystem;
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public string Name => "EPUB Metadata";
+
+ /// <inheritdoc />
+ public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var path = GetEpubFile(info.Path)?.FullName;
+
+ if (path is null)
+ {
+ return Task.FromResult(new MetadataResult<Book> { HasMetadata = false });
+ }
+
+ var result = ReadEpubAsZip(path, cancellationToken);
+
+ if (result is null)
+ {
+ return Task.FromResult(new MetadataResult<Book> { HasMetadata = false });
+ }
+ else
+ {
+ return Task.FromResult(result);
+ }
+ }
+
+ private FileSystemMetadata? GetEpubFile(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+
+ if (fileInfo.IsDirectory)
+ {
+ return null;
+ }
+
+ if (!string.Equals(Path.GetExtension(fileInfo.FullName), ".epub", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return fileInfo;
+ }
+
+ private MetadataResult<Book>? ReadEpubAsZip(string path, CancellationToken cancellationToken)
+ {
+ using var epub = ZipFile.OpenRead(path);
+
+ var opfFilePath = EpubUtils.ReadContentFilePath(epub);
+ if (opfFilePath == null)
+ {
+ return null;
+ }
+
+ var opf = epub.GetEntry(opfFilePath);
+ if (opf == null)
+ {
+ return null;
+ }
+
+ using var opfStream = opf.Open();
+
+ var opfDocument = new XmlDocument();
+ opfDocument.Load(opfStream);
+
+ var utilities = new OpfReader<EpubProvider>(opfDocument, _logger);
+ return utilities.ReadOpfData(cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs
new file mode 100644
index 000000000..e5d298731
--- /dev/null
+++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/EpubUtils.cs
@@ -0,0 +1,35 @@
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Xml.Linq;
+
+namespace MediaBrowser.Providers.Books.OpenPackagingFormat
+{
+ /// <summary>
+ /// Utilities for EPUB files.
+ /// </summary>
+ public static class EpubUtils
+ {
+ /// <summary>
+ /// Attempt to read content from ZIP archive.
+ /// </summary>
+ /// <param name="epub">The ZIP archive.</param>
+ /// <returns>The content file path.</returns>
+ public static string? ReadContentFilePath(ZipArchive epub)
+ {
+ var container = epub.GetEntry(Path.Combine("META-INF", "container.xml"));
+ if (container == null)
+ {
+ return null;
+ }
+
+ using var containerStream = container.Open();
+
+ XNamespace containerNamespace = "urn:oasis:names:tc:opendocument:xmlns:container";
+ var containerDocument = XDocument.Load(containerStream);
+ var element = containerDocument.Descendants(containerNamespace + "rootfile").FirstOrDefault();
+
+ return element?.Attribute("full-path")?.Value;
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs
new file mode 100644
index 000000000..6e678802c
--- /dev/null
+++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfProvider.cs
@@ -0,0 +1,94 @@
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.OpenPackagingFormat
+{
+ /// <summary>
+ /// Provides metadata for book items that have an OPF file in the same directory. Supports the standard
+ /// content.opf filename, bespoke metadata.opf name from Calibre libraries, and OPF files that have the
+ /// same name as their respective books for directories with several books.
+ /// </summary>
+ public class OpfProvider : ILocalMetadataProvider<Book>, IHasItemChangeMonitor
+ {
+ private const string StandardOpfFile = "content.opf";
+ private const string CalibreOpfFile = "metadata.opf";
+
+ private readonly IFileSystem _fileSystem;
+
+ private readonly ILogger<OpfProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="OpfProvider"/> class.
+ /// </summary>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{OpfProvider}"/> interface.</param>
+ public OpfProvider(IFileSystem fileSystem, ILogger<OpfProvider> logger)
+ {
+ _fileSystem = fileSystem;
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public string Name => "Open Packaging Format";
+
+ /// <inheritdoc />
+ public bool HasChanged(BaseItem item, IDirectoryService directoryService)
+ {
+ var file = GetXmlFile(item.Path);
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ /// <inheritdoc />
+ public Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var path = GetXmlFile(info.Path).FullName;
+
+ try
+ {
+ return Task.FromResult(ReadOpfData(path, cancellationToken));
+ }
+ catch (FileNotFoundException)
+ {
+ return Task.FromResult(new MetadataResult<Book> { HasMetadata = false });
+ }
+ }
+
+ private FileSystemMetadata GetXmlFile(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+ var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!);
+
+ // check for OPF with matching name first since it's the most specific filename
+ var specificFile = Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".opf");
+ var file = _fileSystem.GetFileInfo(specificFile);
+
+ if (file.Exists)
+ {
+ return file;
+ }
+
+ file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, StandardOpfFile));
+
+ // check metadata.opf last since it's really only used by Calibre
+ return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, CalibreOpfFile));
+ }
+
+ private MetadataResult<Book> ReadOpfData(string file, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var doc = new XmlDocument();
+ doc.Load(file);
+
+ var utilities = new OpfReader<OpfProvider>(doc, _logger);
+ return utilities.ReadOpfData(cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
new file mode 100644
index 000000000..5d202c59e
--- /dev/null
+++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
@@ -0,0 +1,329 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Xml;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Net;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.OpenPackagingFormat
+{
+ /// <summary>
+ /// Methods used to pull metadata and other information from Open Packaging Format in XML objects.
+ /// </summary>
+ /// <typeparam name="TCategoryName">The type of category.</typeparam>
+ public class OpfReader<TCategoryName>
+ {
+ private const string DcNamespace = @"http://purl.org/dc/elements/1.1/";
+ private const string OpfNamespace = @"http://www.idpf.org/2007/opf";
+
+ private readonly XmlNamespaceManager _namespaceManager;
+ private readonly XmlDocument _document;
+
+ private readonly ILogger<TCategoryName> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="OpfReader{TCategoryName}"/> class.
+ /// </summary>
+ /// <param name="document">The XML document to parse.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{TCategoryName}"/> interface.</param>
+ public OpfReader(XmlDocument document, ILogger<TCategoryName> logger)
+ {
+ _document = document;
+ _logger = logger;
+ _namespaceManager = new XmlNamespaceManager(_document.NameTable);
+
+ _namespaceManager.AddNamespace("dc", DcNamespace);
+ _namespaceManager.AddNamespace("opf", OpfNamespace);
+ }
+
+ /// <summary>
+ /// Checks for the existence of a cover image.
+ /// </summary>
+ /// <param name="opfRootDirectory">The root directory in which the OPF file is located.</param>
+ /// <returns>Returns the found cover and its type or null.</returns>
+ public (string MimeType, string Path)? ReadCoverPath(string opfRootDirectory)
+ {
+ var coverImage = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@properties='cover-image']");
+ if (coverImage is not null)
+ {
+ return coverImage;
+ }
+
+ var coverId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='cover' and @media-type='image/*']");
+ if (coverId is not null)
+ {
+ return coverId;
+ }
+
+ var coverImageId = ReadEpubCoverInto(opfRootDirectory, "//opf:item[@id='*cover-image']");
+ if (coverImageId is not null)
+ {
+ return coverImageId;
+ }
+
+ var metaCoverImage = _document.SelectSingleNode("//opf:meta[@name='cover']", _namespaceManager);
+ var content = metaCoverImage?.Attributes?["content"]?.Value;
+ if (string.IsNullOrEmpty(content) || metaCoverImage is null)
+ {
+ return null;
+ }
+
+ var coverPath = Path.Combine("Images", content);
+ var coverFileManifest = _document.SelectSingleNode($"//opf:item[@href='{coverPath}']", _namespaceManager);
+ var mediaType = coverFileManifest?.Attributes?["media-type"]?.Value;
+ if (coverFileManifest?.Attributes is not null && !string.IsNullOrEmpty(mediaType) && IsValidImage(mediaType))
+ {
+ return (mediaType, Path.Combine(opfRootDirectory, coverPath));
+ }
+
+ var coverFileIdManifest = _document.SelectSingleNode($"//opf:item[@id='{content}']", _namespaceManager);
+ if (coverFileIdManifest is not null)
+ {
+ return ReadManifestItem(coverFileIdManifest, opfRootDirectory);
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ /// Read all supported OPF data from the file.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The metadata result to update.</returns>
+ public MetadataResult<Book> ReadOpfData(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var book = CreateBookFromOpf();
+ var result = new MetadataResult<Book> { Item = book, HasMetadata = true };
+
+ FindAuthors(result);
+ ReadStringInto("//dc:language", language => result.ResultLanguage = language);
+
+ return result;
+ }
+
+ private Book CreateBookFromOpf()
+ {
+ var book = new Book
+ {
+ Name = FindMainTitle(),
+ ForcedSortName = FindSortTitle(),
+ };
+
+ ReadStringInto("//dc:description", summary => book.Overview = summary);
+ ReadStringInto("//dc:publisher", publisher => book.AddStudio(publisher));
+ ReadStringInto("//dc:identifier[@opf:scheme='AMAZON']", amazon => book.SetProviderId("Amazon", amazon));
+ ReadStringInto("//dc:identifier[@opf:scheme='GOOGLE']", google => book.SetProviderId("GoogleBooks", google));
+ ReadStringInto("//dc:identifier[@opf:scheme='ISBN']", isbn => book.SetProviderId("ISBN", isbn));
+
+ ReadStringInto("//dc:date", date =>
+ {
+ if (DateTime.TryParse(date, out var dateValue))
+ {
+ book.PremiereDate = dateValue.Date;
+ book.ProductionYear = dateValue.Date.Year;
+ }
+ });
+
+ var genreNodes = _document.SelectNodes("//dc:subject", _namespaceManager);
+
+ if (genreNodes?.Count > 0)
+ {
+ foreach (var node in genreNodes.Cast<XmlNode>().Where(node => !string.IsNullOrEmpty(node.InnerText) && !book.Genres.Contains(node.InnerText)))
+ {
+ // specification has no rules about content and some books combine every genre into a single element
+ foreach (var item in node.InnerText.Split(["/", "&", ",", ";", " - "], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ book.AddGenre(item);
+ }
+ }
+ }
+
+ ReadInt32AttributeInto("//opf:meta[@name='calibre:series_index']", index => book.IndexNumber = index);
+ ReadInt32AttributeInto("//opf:meta[@name='calibre:rating']", rating => book.CommunityRating = rating);
+
+ var seriesNameNode = _document.SelectSingleNode("//opf:meta[@name='calibre:series']", _namespaceManager);
+
+ if (!string.IsNullOrEmpty(seriesNameNode?.Attributes?["content"]?.Value))
+ {
+ try
+ {
+ book.SeriesName = seriesNameNode.Attributes["content"]?.Value;
+ }
+ catch (Exception)
+ {
+ _logger.LogError("error parsing Calibre series name");
+ }
+ }
+
+ return book;
+ }
+
+ private string FindMainTitle()
+ {
+ var title = string.Empty;
+ var titleTypes = _document.SelectNodes("//opf:meta[@property='title-type']", _namespaceManager);
+
+ if (titleTypes is not null && titleTypes.Count > 0)
+ {
+ foreach (XmlElement titleNode in titleTypes)
+ {
+ string refines = titleNode.GetAttribute("refines").TrimStart('#');
+ string titleType = titleNode.InnerText;
+
+ var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
+ if (titleElement is not null && string.Equals(titleType, "main", StringComparison.OrdinalIgnoreCase))
+ {
+ title = titleElement.InnerText;
+ }
+ }
+ }
+
+ // fallback in case there is no main title definition
+ if (string.IsNullOrEmpty(title))
+ {
+ ReadStringInto("//dc:title", titleString => title = titleString);
+ }
+
+ return title;
+ }
+
+ private string? FindSortTitle()
+ {
+ var titleTypes = _document.SelectNodes("//opf:meta[@property='file-as']", _namespaceManager);
+
+ if (titleTypes is not null && titleTypes.Count > 0)
+ {
+ foreach (XmlElement titleNode in titleTypes)
+ {
+ string refines = titleNode.GetAttribute("refines").TrimStart('#');
+ string sortTitle = titleNode.InnerText;
+
+ var titleElement = _document.SelectSingleNode($"//dc:title[@id='{refines}']", _namespaceManager);
+ if (titleElement is not null)
+ {
+ return sortTitle;
+ }
+ }
+ }
+
+ // search for OPF 2.0 style title_sort node
+ var resultElement = _document.SelectSingleNode("//opf:meta[@name='calibre:title_sort']", _namespaceManager);
+ var titleSort = resultElement?.Attributes?["content"]?.Value;
+
+ return titleSort;
+ }
+
+ private void FindAuthors(MetadataResult<Book> book)
+ {
+ var resultElement = _document.SelectNodes("//dc:creator", _namespaceManager);
+
+ if (resultElement != null && resultElement.Count > 0)
+ {
+ foreach (XmlElement creator in resultElement)
+ {
+ var creatorName = creator.InnerText;
+ var role = creator.GetAttribute("opf:role");
+ var person = new PersonInfo { Name = creatorName, Type = GetRole(role) };
+
+ book.AddPerson(person);
+ }
+ }
+ }
+
+ private PersonKind GetRole(string? role)
+ {
+ switch (role)
+ {
+ case "arr":
+ return PersonKind.Arranger;
+ case "art":
+ return PersonKind.Artist;
+ case "aut":
+ case "aqt":
+ case "aft":
+ case "aui":
+ default:
+ return PersonKind.Author;
+ case "edt":
+ return PersonKind.Editor;
+ case "ill":
+ return PersonKind.Illustrator;
+ case "lyr":
+ return PersonKind.Lyricist;
+ case "mus":
+ return PersonKind.AlbumArtist;
+ case "oth":
+ return PersonKind.Unknown;
+ case "trl":
+ return PersonKind.Translator;
+ }
+ }
+
+ private void ReadStringInto(string xmlPath, Action<string> commitResult)
+ {
+ var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
+ if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.InnerText))
+ {
+ commitResult(resultElement.InnerText);
+ }
+ }
+
+ private void ReadInt32AttributeInto(string xmlPath, Action<int> commitResult)
+ {
+ var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
+ var resultValue = resultElement?.Attributes?["content"]?.Value;
+
+ if (!string.IsNullOrEmpty(resultValue))
+ {
+ try
+ {
+ commitResult(Convert.ToInt32(Convert.ToDouble(resultValue, CultureInfo.InvariantCulture)));
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "error converting to Int32");
+ }
+ }
+ }
+
+ private (string MimeType, string Path)? ReadEpubCoverInto(string opfRootDirectory, string xmlPath)
+ {
+ var resultElement = _document.SelectSingleNode(xmlPath, _namespaceManager);
+
+ if (resultElement is not null)
+ {
+ return ReadManifestItem(resultElement, opfRootDirectory);
+ }
+
+ return null;
+ }
+
+ private (string MimeType, string Path)? ReadManifestItem(XmlNode manifestNode, string opfRootDirectory)
+ {
+ var href = manifestNode.Attributes?["href"]?.Value;
+ var mediaType = manifestNode.Attributes?["media-type"]?.Value;
+
+ if (string.IsNullOrEmpty(href) || string.IsNullOrEmpty(mediaType) || !IsValidImage(mediaType))
+ {
+ return null;
+ }
+
+ var coverPath = Path.Combine(opfRootDirectory, href);
+
+ return (MimeType: mediaType, Path: coverPath);
+ }
+
+ private static bool IsValidImage(string? mimeType)
+ {
+ return !string.IsNullOrEmpty(mimeType) && !string.IsNullOrWhiteSpace(MimeTypes.ToExtension(mimeType));
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
index 75882a088..e0354dbdf 100644
--- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs
+++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
@@ -88,7 +88,15 @@ namespace MediaBrowser.Providers.Manager
}
}
- singular.AddRange(item.GetImages(ImageType.Backdrop));
+ foreach (var backdrop in item.GetImages(ImageType.Backdrop))
+ {
+ var imageInMetadataFolder = backdrop.Path.StartsWith(itemMetadataPath, StringComparison.OrdinalIgnoreCase);
+ if (imageInMetadataFolder || canDeleteLocal || item.IsSaveLocalMetadataEnabled())
+ {
+ singular.Add(backdrop);
+ }
+ }
+
PruneImages(item, singular);
return singular.Count > 0;
@@ -466,10 +474,36 @@ namespace MediaBrowser.Providers.Manager
}
}
- if (UpdateMultiImages(item, images, ImageType.Backdrop))
+ bool hasBackdrop = false;
+ bool backdropStoredWithMedia = false;
+
+ foreach (var image in images)
{
- changed = true;
- foundImageTypes.Add(ImageType.Backdrop);
+ if (image.Type != ImageType.Backdrop)
+ {
+ continue;
+ }
+
+ hasBackdrop = true;
+
+ if (item.ContainingFolderPath is not null && item.ContainingFolderPath.Contains(Path.GetDirectoryName(image.FileInfo.FullName), StringComparison.OrdinalIgnoreCase))
+ {
+ backdropStoredWithMedia = true;
+ break;
+ }
+ }
+
+ if (hasBackdrop)
+ {
+ if (UpdateMultiImages(item, images, ImageType.Backdrop))
+ {
+ changed = true;
+ }
+
+ if (backdropStoredWithMedia)
+ {
+ foundImageTypes.Add(ImageType.Backdrop);
+ }
}
if (foundImageTypes.Count > 0)
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index a2102ca9c..e9cb46eab 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -153,7 +153,7 @@ namespace MediaBrowser.Providers.Manager
if (isFirstRefresh)
{
- await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
+ await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, false, cancellationToken).ConfigureAwait(false);
}
// Next run metadata providers
@@ -247,7 +247,7 @@ namespace MediaBrowser.Providers.Manager
}
// Save to database
- await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false);
+ await SaveItemAsync(metadataResult, updateType, isFirstRefresh, cancellationToken).ConfigureAwait(false);
}
return updateType;
@@ -275,9 +275,14 @@ namespace MediaBrowser.Providers.Manager
}
}
- protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken)
+ protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken)
{
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
+ if (reattachUserData)
+ {
+ await result.Item.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
+ }
+
if (result.Item.SupportsPeople && result.People is not null)
{
var baseItem = result.Item;
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index 43f0746ba..f8e2aece1 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -721,8 +721,6 @@ namespace MediaBrowser.Providers.Manager
}
}
}
-
- _libraryManager.CreateItem(item, null);
}
/// <summary>
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index 34b3104b0..ed0c63b97 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -18,7 +18,6 @@
<PackageReference Include="AsyncKeyedLock" />
<PackageReference Include="LrcParser" />
<PackageReference Include="MetaBrainz.MusicBrainz" />
- <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Newtonsoft.Json" />
@@ -28,7 +27,7 @@
</ItemGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
index 8df15e440..e0a4c4f32 100644
--- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
+++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
@@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
}
else
{
- targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
+ targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
}
if (replaceData || targetItem.Shares.Count == 0)
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
index c35324746..88c8e4f7c 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
+using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
@@ -83,7 +84,9 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
if (!string.IsNullOrEmpty(releaseGroupId))
{
var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false);
- return GetReleaseGroupResult(releaseGroupResult.Releases);
+
+ // No need to pass the cancellation token to GetReleaseGroupResultAsync as we're already passing it to ToBlockingEnumerable
+ return GetReleaseGroupResultAsync(releaseGroupResult.Releases, CancellationToken.None).ToBlockingEnumerable(cancellationToken);
}
var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId();
@@ -128,7 +131,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
}
}
- private IEnumerable<RemoteSearchResult> GetReleaseGroupResult(IEnumerable<IRelease>? releaseSearchResults)
+ private async IAsyncEnumerable<RemoteSearchResult> GetReleaseGroupResultAsync(IEnumerable<IRelease>? releaseSearchResults, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (releaseSearchResults is null)
{
@@ -138,7 +141,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
foreach (var result in releaseSearchResults)
{
// Fetch full release info, otherwise artists are missing
- var fullResult = _musicBrainzQuery.LookupRelease(result.Id, Include.Artists | Include.ReleaseGroups);
+ var fullResult = await _musicBrainzQuery.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
yield return GetReleaseResult(fullResult);
}
}
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 414a0a3c9..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);
+ }
}
}
@@ -303,9 +316,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
CrewMember = crewMember,
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
})
- .Where(entry =>
- TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
- TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
+ .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
if (config.HideMissingCrewMembers)
{
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/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
index e30c555cb..f0e159f09 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
@@ -275,9 +275,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
CrewMember = crewMember,
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
})
- .Where(entry =>
- TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
- TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
+ .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
if (config.HideMissingCrewMembers)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
index 1b429039e..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;
@@ -120,9 +120,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
CrewMember = crewMember,
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
})
- .Where(entry =>
- TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
- TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
+ .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
if (config.HideMissingCrewMembers)
{
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 f0828e826..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)
@@ -367,9 +379,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
CrewMember = crewMember,
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
})
- .Where(entry =>
- TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
- TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
+ .Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
if (config.HideMissingCrewMembers)
{
@@ -390,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 f5e59a278..39c0497be 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
@@ -69,19 +69,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <returns>The Jellyfin person type.</returns>
public static PersonKind MapCrewToPersonType(Crew crew)
{
- if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
- && crew.Job.Contains("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.Contains("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))
+ 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;
}
@@ -96,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>
@@ -116,14 +117,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
preferredLanguage = NormalizeLanguage(preferredLanguage, countryCode);
languages.Add(preferredLanguage);
-
- if (preferredLanguage.Length == 5) // Like en-US
- {
- // Currently, TMDb supports 2-letter language codes only.
- // They are planning to change this in the future, thus we're
- // supplying both codes if we're having a 5-letter code.
- languages.Add(preferredLanguage.Substring(0, 2));
- }
}
languages.Add("null");
@@ -184,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/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj
index b195af96c..cfb3533f3 100644
--- a/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj
+++ b/MediaBrowser.XbmcMetadata/MediaBrowser.XbmcMetadata.csproj
@@ -15,7 +15,7 @@
</ItemGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/README.md b/README.md
index 9830e8e9c..753148186 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@
---
-Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. It is an alternative to the proprietary Emby and Plex, to provide media from a dedicated server to end-user devices via multiple apps. Jellyfin is descended from Emby's 3.5.2 release and ported to the .NET platform to enable full cross-platform support.
+Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. It is an alternative to the proprietary Emby and Plex, to provide media from a dedicated server to end-user devices via multiple apps. Jellyfin is descended from Emby's 3.5.2 release and ported to the .NET platform to enable full cross-platform support.
There are no strings attached, no premium licenses or features, and no hidden agendas: just a team that wants to build something better and work together to achieve it. We welcome anyone who is interested in joining us in our quest!
@@ -94,13 +94,12 @@ git clone https://github.com/jellyfin/jellyfin.git
The server is configured to host the static files required for the [web client](https://github.com/jellyfin/jellyfin-web) in addition to serving the backend by default. Before you can run the server, you will need to get a copy of the web client since they are not included in this repository directly.
-Note that it is also possible to [host the web client separately](#hosting-the-web-client-separately) from the web server with some additional configuration, in which case you can skip this step.
+Note that it is recommended for development to [host the web client separately](#hosting-the-web-client-separately) from the web server with some additional configuration, in which case you can skip this step.
-There are three options to get the files for the web client.
+There are two options to get the files for the web client.
-1. Download one of the finished builds from the [Azure DevOps pipeline](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27). You can download the build for a specific release by looking at the [branches tab](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27&_a=summary&repositoryFilter=6&view=branches) of the pipelines page.
-2. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web)
-3. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web`
+1. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web)
+2. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web`
### Running The Server
@@ -133,7 +132,7 @@ A second option is to build the project and then run the resulting executable fi
```bash
dotnet build # Build the project
-cd Jellyfin.Server/bin/Debug/net9.0 # Change into the build output directory
+cd Jellyfin.Server/bin/Debug/net10.0 # Change into the build output directory
```
2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`.
@@ -198,5 +197,5 @@ This project is supported by:
<br/>
<a href="https://www.digitalocean.com"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50px" alt="DigitalOcean"></a>
&nbsp;
-<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/fa104b7d73f759d7262794b94569f1b89df41c0b/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
+<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/199ae22980ef5da64882ec2de3e8e5c03fe535b8/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
</p>
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 1373d2fe0..1ac7402f9 100644
--- a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj
+++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
diff --git a/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh
index 8183bb37a..771aa6677 100755
--- a/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh
+++ b/fuzz/Emby.Server.Implementations.Fuzz/fuzz.sh
@@ -8,4 +8,4 @@ cp bin/Emby.Server.Implementations.dll .
dotnet build
mkdir -p Findings
-AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net9.0/Emby.Server.Implementations.Fuzz "$1"
+AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net10.0/Emby.Server.Implementations.Fuzz "$1"
diff --git a/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj b/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj
index 04c7be11d..dad2f8e4e 100644
--- a/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj
+++ b/fuzz/Jellyfin.Api.Fuzz/Jellyfin.Api.Fuzz.csproj
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
diff --git a/fuzz/Jellyfin.Api.Fuzz/fuzz.sh b/fuzz/Jellyfin.Api.Fuzz/fuzz.sh
index 15148e1bb..537de905d 100755
--- a/fuzz/Jellyfin.Api.Fuzz/fuzz.sh
+++ b/fuzz/Jellyfin.Api.Fuzz/fuzz.sh
@@ -8,4 +8,4 @@ cp bin/Jellyfin.Api.dll .
dotnet build
mkdir -p Findings
-AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net9.0/Jellyfin.Api.Fuzz "$1"
+AFL_SKIP_BIN_CHECK=1 afl-fuzz -i "Testcases/$1" -o "Findings/$1" -t 5000 ./bin/Debug/net10.0/Jellyfin.Api.Fuzz "$1"
diff --git a/global.json b/global.json
index 2e13a6387..867a4cfa0 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "9.0.0",
+ "version": "10.0.0",
"rollForward": "latestMinor"
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
index 28c4972d2..0b29a71cb 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
index 7bcc7eeca..76ffa5a9e 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1873
+
using System;
using System.Data.Common;
using System.Linq;
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
index 2d6bc6902..404292e8e 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
@@ -1,5 +1,6 @@
#pragma warning disable MT1013 // Releasing lock without guarantee of execution
#pragma warning disable MT1012 // Acquiring lock without guarantee of releasing
+#pragma warning disable CA1873
using System;
using System.Data;
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj
index 03e5fc495..aeee52701 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Jellyfin.Database.Providers.Sqlite.csproj
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
index ba402dfe0..f7c20463f 100644
--- a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
+++ b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj
@@ -6,7 +6,7 @@
</PropertyGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- TODO: Remove once we update SkiaSharp > 2.88.5 -->
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index 503e2f941..c6eab92ea 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -209,39 +209,69 @@ public class SkiaEncoder : IImageEncoder
return default;
}
- using var codec = SKCodec.Create(safePath, out var result);
-
- switch (result)
- {
- case SKCodecResult.Success:
- // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel
- // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.)
- // `SKCodec.Create` returns a *non‑null* codec together with
- // SKCodecResult.InternalError. The header still contains valid dimensions,
- // which is all we need here – so we fall back to them instead of aborting.
- // See e.g. Skia bugs #4139, #6092.
- case SKCodecResult.InternalError when codec is not null:
- var info = codec.Info;
- return new ImageDimensions(info.Width, info.Height);
-
- case SKCodecResult.Unimplemented:
- _logger.LogDebug("Image format not supported: {FilePath}", path);
- return default;
-
- default:
+ SKCodec? codec = null;
+ bool isSafePathTemp = !string.Equals(Path.GetFullPath(safePath), Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase);
+ try
+ {
+ codec = SKCodec.Create(safePath, out var result);
+ switch (result)
{
- var boundsInfo = SKBitmap.DecodeBounds(safePath);
+ case SKCodecResult.Success:
+ // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel
+ // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.)
+ // `SKCodec.Create` returns a *non‑null* codec together with
+ // SKCodecResult.InternalError. The header still contains valid dimensions,
+ // which is all we need here – so we fall back to them instead of aborting.
+ // See e.g. Skia bugs #4139, #6092.
+ case SKCodecResult.InternalError when codec is not null:
+ var info = codec.Info;
+ return new ImageDimensions(info.Width, info.Height);
+
+ case SKCodecResult.Unimplemented:
+ _logger.LogDebug("Image format not supported: {FilePath}", path);
+ return default;
- if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
+ default:
{
- return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
+ var boundsInfo = SKBitmap.DecodeBounds(safePath);
+ if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
+ {
+ return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
+ }
+
+ _logger.LogWarning(
+ "Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
+ path,
+ result);
+
+ return default;
}
+ }
+ }
+ finally
+ {
+ try
+ {
+ codec?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Error by closing codec for {FilePath}", safePath);
+ }
- _logger.LogWarning(
- "Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
- path,
- result);
- return default;
+ if (isSafePathTemp)
+ {
+ try
+ {
+ if (File.Exists(safePath))
+ {
+ File.Delete(safePath);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Unable to remove temporary file '{TempPath}'", safePath);
+ }
}
}
}
diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs
index 46e5213a8..6ffb02284 100644
--- a/src/Jellyfin.Drawing/ImageProcessor.cs
+++ b/src/Jellyfin.Drawing/ImageProcessor.cs
@@ -85,7 +85,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
"jpeg",
"jpg",
"png",
- "aiff",
"cr2",
"crw",
"nef",
diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj
index 5f4b3fe8d..a442f7457 100644
--- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj
+++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj
@@ -6,7 +6,7 @@
</PropertyGroup>
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/src/Jellyfin.Extensions/AlphanumericComparator.cs b/src/Jellyfin.Extensions/AlphanumericComparator.cs
deleted file mode 100644
index 299e2f94a..000000000
--- a/src/Jellyfin.Extensions/AlphanumericComparator.cs
+++ /dev/null
@@ -1,112 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-namespace Jellyfin.Extensions
-{
- /// <summary>
- /// Alphanumeric <see cref="IComparer{T}" />.
- /// </summary>
- public class AlphanumericComparator : IComparer<string?>
- {
- /// <summary>
- /// Compares two objects and returns a value indicating whether one is less than, equal to, or greater than the other.
- /// </summary>
- /// <param name="s1">The first object to compare.</param>
- /// <param name="s2">The second object to compare.</param>
- /// <returns>A signed integer that indicates the relative values of <c>x</c> and <c>y</c>.</returns>
- public static int CompareValues(string? s1, string? s2)
- {
- if (s1 is null && s2 is null)
- {
- return 0;
- }
-
- if (s1 is null)
- {
- return -1;
- }
-
- if (s2 is null)
- {
- return 1;
- }
-
- int len1 = s1.Length;
- int len2 = s2.Length;
-
- // Early return for empty strings
- if (len1 == 0 && len2 == 0)
- {
- return 0;
- }
-
- if (len1 == 0)
- {
- return -1;
- }
-
- if (len2 == 0)
- {
- return 1;
- }
-
- int pos1 = 0;
- int pos2 = 0;
-
- do
- {
- int start1 = pos1;
- int start2 = pos2;
-
- bool isNum1 = char.IsDigit(s1[pos1++]);
- bool isNum2 = char.IsDigit(s2[pos2++]);
-
- while (pos1 < len1 && char.IsDigit(s1[pos1]) == isNum1)
- {
- pos1++;
- }
-
- while (pos2 < len2 && char.IsDigit(s2[pos2]) == isNum2)
- {
- pos2++;
- }
-
- var span1 = s1.AsSpan(start1, pos1 - start1);
- var span2 = s2.AsSpan(start2, pos2 - start2);
-
- if (isNum1 && isNum2)
- {
- // Trim leading zeros so we can compare the length
- // of the strings to find the largest number
- span1 = span1.TrimStart('0');
- span2 = span2.TrimStart('0');
- var span1Len = span1.Length;
- var span2Len = span2.Length;
- if (span1Len < span2Len)
- {
- return -1;
- }
-
- if (span1Len > span2Len)
- {
- return 1;
- }
- }
-
- int result = span1.CompareTo(span2, StringComparison.InvariantCulture);
- if (result != 0)
- {
- return result;
- }
- } while (pos1 < len1 && pos2 < len2);
-
- return len1 - len2;
- }
-
- /// <inheritdoc />
- public int Compare(string? x, string? y)
- {
- return CompareValues(x, y);
- }
- }
-}
diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
index f52fd014d..9a7cf4aab 100644
--- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
+++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
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());
}
diff --git a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj
index f04c02504..575441de9 100644
--- a/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj
+++ b/src/Jellyfin.LiveTv/Jellyfin.LiveTv.csproj
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
@@ -13,7 +13,6 @@
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" />
<PackageReference Include="Jellyfin.XmlTv" />
- <PackageReference Include="System.Linq.Async" />
</ItemGroup>
<ItemGroup>
diff --git a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj
index 80b5aa84e..902f51376 100644
--- a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj
+++ b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
@@ -12,9 +12,6 @@
<ProjectReference Include="../Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj" />
</ItemGroup>
- <ItemGroup>
- <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
- </ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj
index cc8d942eb..5e7e2090c 100644
--- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj
+++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
@@ -23,10 +23,6 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
- </ItemGroup>
-
- <ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Jellyfin.MediaEncoding.Keyframes.Tests</_Parameter1>
</AssemblyAttribute>
diff --git a/src/Jellyfin.Networking/Jellyfin.Networking.csproj b/src/Jellyfin.Networking/Jellyfin.Networking.csproj
index 1a146549d..36b9581a7 100644
--- a/src/Jellyfin.Networking/Jellyfin.Networking.csproj
+++ b/src/Jellyfin.Networking/Jellyfin.Networking.csproj
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs
index 126d9f15c..a9136aad4 100644
--- a/src/Jellyfin.Networking/Manager/NetworkManager.cs
+++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs
@@ -16,7 +16,6 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
-using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
namespace Jellyfin.Networking.Manager;
@@ -115,7 +114,7 @@ public class NetworkManager : INetworkManager, IDisposable
public static string MockNetworkSettings { get; set; } = string.Empty;
/// <summary>
- /// Gets a value indicating whether IP4 is enabled.
+ /// Gets a value indicating whether IPv4 is enabled.
/// </summary>
public bool IsIPv4Enabled => _configurationManager.GetNetworkConfiguration().EnableIPv4;
@@ -341,12 +340,12 @@ public class NetworkManager : INetworkManager, IDisposable
}
else
{
- _lanSubnets = lanSubnets;
+ _lanSubnets = lanSubnets.Select(x => x.Subnet).ToArray();
}
_excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true)
- ? excludedSubnets
- : new List<IPNetwork>();
+ ? excludedSubnets.Select(x => x.Subnet).ToArray()
+ : Array.Empty<IPNetwork>();
}
}
@@ -362,7 +361,7 @@ public class NetworkManager : INetworkManager, IDisposable
}
/// <summary>
- /// Filteres a list of bind addresses and exclusions on available interfaces.
+ /// Filters a list of bind addresses and exclusions on available interfaces.
/// </summary>
/// <param name="config">The network config to be filtered by.</param>
/// <param name="interfaces">A list of possible interfaces to be filtered.</param>
@@ -376,7 +375,7 @@ public class NetworkManager : INetworkManager, IDisposable
if (localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0]))
{
var bindAddresses = localNetworkAddresses.Select(p => NetworkUtils.TryParseToSubnet(p, out var network)
- ? network.Prefix
+ ? network.Address
: (interfaces.Where(x => x.Name.Equals(p, StringComparison.OrdinalIgnoreCase))
.Select(x => x.Address)
.FirstOrDefault() ?? IPAddress.None))
@@ -445,7 +444,7 @@ public class NetworkManager : INetworkManager, IDisposable
var remoteFilteredSubnets = remoteIPFilter.Where(x => x.Contains('/', StringComparison.OrdinalIgnoreCase)).ToArray();
if (NetworkUtils.TryParseToSubnets(remoteFilteredSubnets, out var remoteAddressFilterResult, false))
{
- remoteAddressFilter = remoteAddressFilterResult.ToList();
+ remoteAddressFilter = remoteAddressFilterResult.Select(x => x.Subnet).ToList();
}
// Parse everything else as an IP and construct subnet with a single IP
@@ -545,7 +544,7 @@ public class NetworkManager : INetworkManager, IDisposable
{
foreach (var lan in _lanSubnets)
{
- var lanPrefix = lan.Prefix;
+ var lanPrefix = lan.BaseAddress;
publishedServerUrls.Add(
new PublishedServerUriOverride(
new IPData(lanPrefix, new IPNetwork(lanPrefix, lan.PrefixLength)),
@@ -554,12 +553,11 @@ public class NetworkManager : INetworkManager, IDisposable
false));
}
}
- else if (NetworkUtils.TryParseToSubnet(identifier, out var result) && result is not null)
+ else if (NetworkUtils.TryParseToSubnet(identifier, out var result))
{
- var data = new IPData(result.Prefix, result);
publishedServerUrls.Add(
new PublishedServerUriOverride(
- data,
+ result,
replacement,
true,
true));
@@ -621,16 +619,12 @@ public class NetworkManager : INetworkManager, IDisposable
foreach (var details in interfaceList)
{
var parts = details.Split(',');
- if (NetworkUtils.TryParseToSubnet(parts[0], out var subnet))
+ if (NetworkUtils.TryParseToSubnet(parts[0], out var data))
{
- var address = subnet.Prefix;
- var index = int.Parse(parts[1], CultureInfo.InvariantCulture);
- if (address.AddressFamily == AddressFamily.InterNetwork || address.AddressFamily == AddressFamily.InterNetworkV6)
+ data.Index = int.Parse(parts[1], CultureInfo.InvariantCulture);
+ if (data.AddressFamily == AddressFamily.InterNetwork || data.AddressFamily == AddressFamily.InterNetworkV6)
{
- var data = new IPData(address, subnet, parts[2])
- {
- Index = index
- };
+ data.Name = parts[2];
interfaces.Add(data);
}
}
@@ -920,7 +914,7 @@ public class NetworkManager : INetworkManager, IDisposable
{
if (NetworkUtils.TryParseToSubnet(address, out var subnet))
{
- return IsInLocalNetwork(subnet.Prefix);
+ return IsInLocalNetwork(subnet.Address);
}
return NetworkUtils.TryParseHost(address, out var addresses, IsIPv4Enabled, IsIPv6Enabled)
@@ -1171,13 +1165,13 @@ public class NetworkManager : INetworkManager, IDisposable
var logLevel = debug ? LogLevel.Debug : LogLevel.Information;
if (_logger.IsEnabled(logLevel))
{
- _logger.Log(logLevel, "Defined LAN subnets: {Subnets}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
- _logger.Log(logLevel, "Defined LAN exclusions: {Subnets}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
- _logger.Log(logLevel, "Used LAN subnets: {Subnets}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength));
+ _logger.Log(logLevel, "Defined LAN subnets: {Subnets}", _lanSubnets.Select(s => s.BaseAddress + "/" + s.PrefixLength));
+ _logger.Log(logLevel, "Defined LAN exclusions: {Subnets}", _excludedSubnets.Select(s => s.BaseAddress + "/" + s.PrefixLength));
+ _logger.Log(logLevel, "Used LAN subnets: {Subnets}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.BaseAddress + "/" + s.PrefixLength));
_logger.Log(logLevel, "Filtered interface addresses: {Addresses}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address));
_logger.Log(logLevel, "Bind Addresses {Addresses}", GetAllBindInterfaces(false).OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address));
_logger.Log(logLevel, "Remote IP filter is {Type}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist");
- _logger.Log(logLevel, "Filtered subnets: {Subnets}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength));
+ _logger.Log(logLevel, "Filtered subnets: {Subnets}", _remoteAddressFilter.Select(s => s.BaseAddress + "/" + s.PrefixLength));
}
}
}
diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props
index 6b851021f..feec35307 100644
--- a/tests/Directory.Build.props
+++ b/tests/Directory.Build.props
@@ -4,7 +4,7 @@
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
index 015018910..6b84c4438 100644
--- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
+++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
@@ -10,7 +10,6 @@
<PackageReference Include="AutoFixture.AutoMoq" />
<PackageReference Include="AutoFixture.Xunit2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
- <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
diff --git a/tests/Jellyfin.Controller.Tests/Entities/InternalItemsQueryTests.cs b/tests/Jellyfin.Controller.Tests/Entities/InternalItemsQueryTests.cs
new file mode 100644
index 000000000..7093b2500
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/InternalItemsQueryTests.cs
@@ -0,0 +1,26 @@
+using System;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Querying;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+public class InternalItemsQueryTests
+{
+ public static TheoryData<ItemFilter[]> ApplyFilters_Invalid()
+ {
+ var data = new TheoryData<ItemFilter[]>();
+ data.Add([ItemFilter.IsFolder, ItemFilter.IsNotFolder]);
+ data.Add([ItemFilter.IsPlayed, ItemFilter.IsUnplayed]);
+ data.Add([ItemFilter.Likes, ItemFilter.Dislikes]);
+ return data;
+ }
+
+ [Theory]
+ [MemberData(nameof(ApplyFilters_Invalid))]
+ public void ApplyFilters_Invalid_ThrowsArgumentException(ItemFilter[] filters)
+ {
+ var query = new InternalItemsQuery();
+ Assert.Throws<ArgumentException>(() => query.ApplyFilters(filters));
+ }
+}
diff --git a/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs b/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs
deleted file mode 100644
index 105e2a52a..000000000
--- a/tests/Jellyfin.Extensions.Tests/AlphanumericComparatorTests.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System;
-using System.Linq;
-using Xunit;
-
-namespace Jellyfin.Extensions.Tests
-{
- public class AlphanumericComparatorTests
- {
- // InlineData is pre-sorted
- [Theory]
- [InlineData(null, "", "1", "9", "10", "a", "z")]
- [InlineData("50F", "100F", "SR9", "SR100")]
- [InlineData("image-1.jpg", "image-02.jpg", "image-4.jpg", "image-9.jpg", "image-10.jpg", "image-11.jpg", "image-22.jpg")]
- [InlineData("Hard drive 2GB", "Hard drive 20GB")]
- [InlineData("b", "e", "è", "ě", "f", "g", "k")]
- [InlineData("123456789", "123456789a", "abc", "abcd")]
- [InlineData("12345678912345678912345678913234567891", "123456789123456789123456789132345678912")]
- [InlineData("12345678912345678912345678913234567891", "12345678912345678912345678913234567891")]
- [InlineData("12345678912345678912345678913234567891", "12345678912345678912345678913234567892")]
- [InlineData("12345678912345678912345678913234567891a", "12345678912345678912345678913234567891a")]
- [InlineData("12345678912345678912345678913234567891a", "12345678912345678912345678913234567891b")]
- [InlineData("a5", "a11")]
- [InlineData("a05a", "a5b")]
- [InlineData("a5a", "a05b")]
- [InlineData("6xxx", "007asdf")]
- [InlineData("00042Q", "42s")]
- public void AlphanumericComparatorTest(params string?[] strings)
- {
- var copy = strings.Reverse().ToArray();
- Array.Sort(copy, new AlphanumericComparator());
- Assert.Equal(strings, copy);
- }
- }
-}
diff --git a/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj
index fdcf7d61e..bdf6bc383 100644
--- a/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj
+++ b/tests/Jellyfin.LiveTv.Tests/Jellyfin.LiveTv.Tests.csproj
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
- <TargetFramework>net9.0</TargetFramework>
+ <TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
index 94710a095..8ebbd029a 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
@@ -39,6 +39,23 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
public void GetFrameRate_Success(string value, float? expected)
=> Assert.Equal(expected, ProbeResultNormalizer.GetFrameRate(value));
+ [Theory]
+ [InlineData("1:1", true)]
+ [InlineData("3201:3200", true)]
+ [InlineData("1215:1216", true)]
+ [InlineData("1001:1000", true)]
+ [InlineData("16:15", false)]
+ [InlineData("8:9", false)]
+ [InlineData("32:27", false)]
+ [InlineData("10:11", false)]
+ [InlineData("64:45", false)]
+ [InlineData("4:3", false)]
+ [InlineData("0:1", false)]
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected)
+ => Assert.Equal(expected, ProbeResultNormalizer.IsNearSquarePixelSar(sar));
+
[Fact]
public void GetMediaInfo_MetaData_Success()
{
@@ -123,6 +140,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.Equal(358, res.VideoStream.Height);
Assert.Equal(720, res.VideoStream.Width);
Assert.Equal("2.40:1", res.VideoStream.AspectRatio);
+ Assert.True(res.VideoStream.IsAnamorphic); // SAR 32:27 — genuinely anamorphic NTSC DVD 16:9
Assert.Equal("yuv420p", res.VideoStream.PixelFormat);
Assert.Equal(31d, res.VideoStream.Level);
Assert.Equal(1, res.VideoStream.RefFrames);
@@ -196,6 +214,18 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
}
[Fact]
+ public void GetMediaInfo_WebM_Like_Mkv()
+ {
+ var bytes = File.ReadAllBytes("Test Data/Probing/video_web_like_mkv_with_subtitle.json");
+ var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
+
+ MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_metadata.mkv", MediaProtocol.File);
+
+ Assert.Equal("mkv", res.Container);
+ Assert.Equal(3, res.MediaStreams.Count);
+ }
+
+ [Fact]
public void GetMediaInfo_ProgressiveVideoNoFieldOrder_Success()
{
var bytes = File.ReadAllBytes("Test Data/Probing/video_progressive_no_field_order.json");
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_web_like_mkv_with_subtitle.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_web_like_mkv_with_subtitle.json
new file mode 100644
index 000000000..4f52dd90d
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_web_like_mkv_with_subtitle.json
@@ -0,0 +1,137 @@
+{
+ "streams": [
+ {
+ "index": 0,
+ "codec_name": "vp8",
+ "codec_long_name": "On2 VP8",
+ "profile": "1",
+ "codec_type": "video",
+ "codec_tag_string": "[0][0][0][0]",
+ "codec_tag": "0x0000",
+ "width": 540,
+ "height": 360,
+ "coded_width": 540,
+ "coded_height": 360,
+ "closed_captions": 0,
+ "film_grain": 0,
+ "has_b_frames": 0,
+ "sample_aspect_ratio": "1:1",
+ "display_aspect_ratio": "3:2",
+ "pix_fmt": "yuv420p",
+ "level": -99,
+ "field_order": "progressive",
+ "refs": 1,
+ "r_frame_rate": "2997/125",
+ "avg_frame_rate": "2997/125",
+ "time_base": "1/1000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "disposition": {
+ "default": 1,
+ "dub": 0,
+ "original": 0,
+ "comment": 0,
+ "lyrics": 0,
+ "karaoke": 0,
+ "forced": 0,
+ "hearing_impaired": 0,
+ "visual_impaired": 0,
+ "clean_effects": 0,
+ "attached_pic": 0,
+ "timed_thumbnails": 0,
+ "captions": 0,
+ "descriptions": 0,
+ "metadata": 0,
+ "dependent": 0,
+ "still_image": 0
+ },
+ "tags": {
+ "language": "eng"
+ }
+ },
+ {
+ "index": 1,
+ "codec_name": "vorbis",
+ "codec_long_name": "Vorbis",
+ "codec_type": "audio",
+ "codec_tag_string": "[0][0][0][0]",
+ "codec_tag": "0x0000",
+ "sample_fmt": "fltp",
+ "sample_rate": "44100",
+ "channels": 2,
+ "channel_layout": "stereo",
+ "bits_per_sample": 0,
+ "r_frame_rate": "0/0",
+ "avg_frame_rate": "0/0",
+ "time_base": "1/1000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "duration": "117.707000",
+ "bit_rate": "127998",
+ "disposition": {
+ "default": 1,
+ "dub": 0,
+ "original": 0,
+ "comment": 0,
+ "lyrics": 0,
+ "karaoke": 0,
+ "forced": 0,
+ "hearing_impaired": 0,
+ "visual_impaired": 0,
+ "clean_effects": 0,
+ "attached_pic": 0,
+ "timed_thumbnails": 0,
+ "captions": 0,
+ "descriptions": 0,
+ "metadata": 0,
+ "dependent": 0,
+ "still_image": 0
+ },
+ "tags": {
+ "language": "eng"
+ }
+ },
+ {
+ "index": 2,
+ "codec_name": "subrip",
+ "codec_long_name": "SubRip subtitle",
+ "codec_type": "subtitle",
+ "codec_tag_string": "[0][0][0][0]",
+ "codec_tag": "0x0000",
+ "disposition": {
+ "default": 0,
+ "dub": 0,
+ "original": 0,
+ "comment": 0,
+ "lyrics": 0,
+ "karaoke": 0,
+ "forced": 0,
+ "hearing_impaired": 0,
+ "visual_impaired": 0,
+ "clean_effects": 0,
+ "attached_pic": 0,
+ "timed_thumbnails": 0,
+ "captions": 0,
+ "descriptions": 0,
+ "metadata": 0,
+ "dependent": 0,
+ "still_image": 0
+ },
+ "tags": {
+ "language": "eng"
+ }
+ }
+ ],
+ "format": {
+ "filename": "sample.mkv",
+ "nb_streams": 3,
+ "nb_programs": 0,
+ "format_name": "matroska,webm",
+ "format_long_name": "Matroska / WebM",
+ "start_time": "0.000000",
+ "duration": "117.700914",
+ "size": "8566268",
+ "bit_rate": "582239",
+ "probe_score": 100
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs b/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs
index e32baef55..6436d7d0e 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/LegacyStreamInfo.cs
@@ -134,8 +134,6 @@ public class LegacyStreamInfo : StreamInfo
{
list.Add(new NameValuePair("MinSegments", item.MinSegments.Value.ToString(CultureInfo.InvariantCulture)));
}
-
- list.Add(new NameValuePair("BreakOnNonKeyFrames", item.BreakOnNonKeyFrames.ToString(CultureInfo.InvariantCulture)));
}
else
{
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs
index 8dea46806..4b3126fe1 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs
@@ -216,8 +216,7 @@ public class StreamInfoTests
string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123");
- // New version will return and & after the ? due to optional parameters.
- string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase);
+ string newUrl = streamInfo.ToUrl(BaseUrl, "123", null);
Assert.Equal(legacyUrl, newUrl, ignoreCase: true);
}
@@ -234,8 +233,7 @@ public class StreamInfoTests
FillAllProperties(streamInfo);
string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123");
- // New version will return and & after the ? due to optional parameters.
- string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase);
+ string newUrl = streamInfo.ToUrl(BaseUrl, "123", null);
Assert.Equal(legacyUrl, newUrl, ignoreCase: true);
}
diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs
index f4c0d9fe8..c1a3a4544 100644
--- a/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs
+++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamTests.cs
@@ -108,6 +108,49 @@ namespace Jellyfin.Model.Tests.Entities
IsExternal = true
});
+ // Test LocalizedLanguage is used when set (fixes zh-CN display issue #15935)
+ data.Add(
+ "Chinese (Simplified) - SRT",
+ new MediaStream
+ {
+ Type = MediaStreamType.Subtitle,
+ Title = null,
+ Language = "zh-CN",
+ LocalizedLanguage = "Chinese (Simplified)",
+ IsForced = false,
+ IsDefault = false,
+ Codec = "SRT"
+ });
+
+ // Test LocalizedLanguage for audio streams
+ data.Add(
+ "Japanese - AAC - Stereo",
+ new MediaStream
+ {
+ Type = MediaStreamType.Audio,
+ Title = null,
+ Language = "jpn",
+ LocalizedLanguage = "Japanese",
+ IsForced = false,
+ IsDefault = false,
+ Codec = "AAC",
+ ChannelLayout = "stereo"
+ });
+
+ // Test fallback to Language when LocalizedLanguage is null
+ data.Add(
+ "Eng - ASS",
+ new MediaStream
+ {
+ Type = MediaStreamType.Subtitle,
+ Title = null,
+ Language = "eng",
+ LocalizedLanguage = null,
+ IsForced = false,
+ IsDefault = false,
+ Codec = "ASS"
+ });
+
return data;
}
diff --git a/tests/Jellyfin.Model.Tests/Extensions/EnumerableExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Extensions/EnumerableExtensionsTests.cs
new file mode 100644
index 000000000..135a139cd
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Extensions/EnumerableExtensionsTests.cs
@@ -0,0 +1,116 @@
+using System.Linq;
+using MediaBrowser.Model.Extensions;
+using MediaBrowser.Model.Providers;
+using Xunit;
+
+namespace Jellyfin.Model.Tests.Extensions;
+
+public class EnumerableExtensionsTests
+{
+ [Fact]
+ public void OrderByLanguageDescending_PreferredLanguageFirst()
+ {
+ var images = new[]
+ {
+ new RemoteImageInfo { Language = "en", CommunityRating = 5.0, VoteCount = 100 },
+ new RemoteImageInfo { Language = "de", CommunityRating = 9.0, VoteCount = 200 },
+ new RemoteImageInfo { Language = null, CommunityRating = 7.0, VoteCount = 50 },
+ new RemoteImageInfo { Language = "fr", CommunityRating = 8.0, VoteCount = 150 },
+ };
+
+ var result = images.OrderByLanguageDescending("de").ToList();
+
+ Assert.Equal("de", result[0].Language);
+ Assert.Equal("en", result[1].Language);
+ Assert.Null(result[2].Language);
+ Assert.Equal("fr", result[3].Language);
+ }
+
+ [Fact]
+ public void OrderByLanguageDescending_EnglishBeforeNoLanguage()
+ {
+ var images = new[]
+ {
+ new RemoteImageInfo { Language = null, CommunityRating = 9.0, VoteCount = 500 },
+ new RemoteImageInfo { Language = "en", CommunityRating = 3.0, VoteCount = 10 },
+ };
+
+ var result = images.OrderByLanguageDescending("de").ToList();
+
+ // English should come before no-language, even with lower rating
+ Assert.Equal("en", result[0].Language);
+ Assert.Null(result[1].Language);
+ }
+
+ [Fact]
+ public void OrderByLanguageDescending_SameLanguageSortedByRatingThenVoteCount()
+ {
+ var images = new[]
+ {
+ new RemoteImageInfo { Language = "de", CommunityRating = 5.0, VoteCount = 100 },
+ new RemoteImageInfo { Language = "de", CommunityRating = 9.0, VoteCount = 50 },
+ new RemoteImageInfo { Language = "de", CommunityRating = 9.0, VoteCount = 200 },
+ };
+
+ var result = images.OrderByLanguageDescending("de").ToList();
+
+ Assert.Equal(200, result[0].VoteCount);
+ Assert.Equal(50, result[1].VoteCount);
+ Assert.Equal(100, result[2].VoteCount);
+ }
+
+ [Fact]
+ public void OrderByLanguageDescending_NullRequestedLanguage_DefaultsToEnglish()
+ {
+ var images = new[]
+ {
+ new RemoteImageInfo { Language = "fr", CommunityRating = 9.0, VoteCount = 500 },
+ new RemoteImageInfo { Language = "en", CommunityRating = 5.0, VoteCount = 10 },
+ };
+
+ var result = images.OrderByLanguageDescending(null!).ToList();
+
+ // With null requested language, English becomes the preferred language (score 4)
+ Assert.Equal("en", result[0].Language);
+ Assert.Equal("fr", result[1].Language);
+ }
+
+ [Fact]
+ public void OrderByLanguageDescending_EnglishRequested_NoDoubleBoost()
+ {
+ // When requested language IS English, "en" gets score 4 (requested match),
+ // no-language gets score 2, others get score 0
+ var images = new[]
+ {
+ new RemoteImageInfo { Language = null, CommunityRating = 9.0, VoteCount = 500 },
+ new RemoteImageInfo { Language = "en", CommunityRating = 3.0, VoteCount = 10 },
+ new RemoteImageInfo { Language = "fr", CommunityRating = 8.0, VoteCount = 300 },
+ };
+
+ var result = images.OrderByLanguageDescending("en").ToList();
+
+ Assert.Equal("en", result[0].Language);
+ Assert.Null(result[1].Language);
+ Assert.Equal("fr", result[2].Language);
+ }
+
+ [Fact]
+ public void OrderByLanguageDescending_FullPriorityOrder()
+ {
+ var images = new[]
+ {
+ new RemoteImageInfo { Language = "fr", CommunityRating = 9.0, VoteCount = 500 },
+ new RemoteImageInfo { Language = null, CommunityRating = 8.0, VoteCount = 400 },
+ new RemoteImageInfo { Language = "en", CommunityRating = 7.0, VoteCount = 300 },
+ new RemoteImageInfo { Language = "de", CommunityRating = 6.0, VoteCount = 200 },
+ };
+
+ var result = images.OrderByLanguageDescending("de").ToList();
+
+ // Expected order: de (requested) > en > no-language > fr (other)
+ Assert.Equal("de", result[0].Language);
+ Assert.Equal("en", result[1].Language);
+ Assert.Null(result[2].Language);
+ Assert.Equal("fr", result[3].Language);
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidPixel.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidPixel.json
index 68ce3ea4a..643ff2638 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidPixel.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidPixel.json
@@ -152,7 +152,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -169,7 +168,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -185,7 +183,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer.json
index 3d3968268..44f63f384 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer.json
@@ -130,7 +130,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -146,7 +145,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome-NoHLS.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome-NoHLS.json
index 5d1f5f162..f1fc9e0db 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome-NoHLS.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome-NoHLS.json
@@ -127,7 +127,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -144,7 +143,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -161,7 +159,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -178,7 +175,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -195,7 +191,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -212,7 +207,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -229,7 +223,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -246,7 +239,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -263,7 +255,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -281,7 +272,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -298,7 +288,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json
index e2f75b569..7e37a6236 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Chrome.json
@@ -107,7 +107,6 @@
"Protocol": "hls",
"MaxAudioChannels": "2",
"MinSegments": "2",
- "BreakOnNonKeyFrames": true,
"EnableAudioVbrEncoding": true
},
{
@@ -182,8 +181,7 @@
"Context": "Streaming",
"Protocol": "hls",
"MaxAudioChannels": "2",
- "MinSegments": "2",
- "BreakOnNonKeyFrames": true
+ "MinSegments": "2"
},
{
"Container": "ts",
@@ -193,8 +191,7 @@
"Context": "Streaming",
"Protocol": "hls",
"MaxAudioChannels": "2",
- "MinSegments": "2",
- "BreakOnNonKeyFrames": true
+ "MinSegments": "2"
}
],
"ContainerProfiles": [],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json
index 21ae7e5cb..4380d80ef 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Firefox.json
@@ -95,7 +95,6 @@
"TranscodingProfiles": [
{
"AudioCodec": "aac",
- "BreakOnNonKeyFrames": true,
"Container": "mp4",
"Context": "Streaming",
"EnableAudioVbrEncoding": true,
@@ -170,7 +169,6 @@
},
{
"AudioCodec": "aac,mp2,opus,flac",
- "BreakOnNonKeyFrames": true,
"Container": "mp4",
"Context": "Streaming",
"MaxAudioChannels": "2",
@@ -181,7 +179,6 @@
},
{
"AudioCodec": "aac,mp3,mp2",
- "BreakOnNonKeyFrames": true,
"Container": "ts",
"Context": "Streaming",
"MaxAudioChannels": "2",
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-JellyfinMediaPlayer.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-JellyfinMediaPlayer.json
index da9a1a4ad..cca1c16ee 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-JellyfinMediaPlayer.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-JellyfinMediaPlayer.json
@@ -30,7 +30,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -48,7 +47,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -62,7 +60,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-LowBandwidth.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-LowBandwidth.json
index 82b73fb0f..b7cd170b9 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-LowBandwidth.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-LowBandwidth.json
@@ -30,7 +30,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -48,7 +47,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -62,7 +60,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlus.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlus.json
index 37b923558..b823ac4b8 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlus.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlus.json
@@ -49,7 +49,6 @@
"MaxAudioChannels": " 2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -66,7 +65,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -83,7 +81,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -100,7 +97,6 @@
"MaxAudioChannels": " 2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -118,7 +114,6 @@
"MaxAudioChannels": " 2",
"MinSegments": 1,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -135,7 +130,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlusNext.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlusNext.json
index 542bf6370..708ff73c4 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlusNext.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-RokuSSPlusNext.json
@@ -49,7 +49,6 @@
"MaxAudioChannels": " 2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -66,7 +65,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -83,7 +81,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -100,7 +97,6 @@
"MaxAudioChannels": " 2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -118,7 +114,6 @@
"MaxAudioChannels": " 2",
"MinSegments": 1,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -135,7 +130,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json
index f61d0e36b..10382fa82 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-SafariNext.json
@@ -114,7 +114,6 @@
"Protocol": "hls",
"MaxAudioChannels": "6",
"MinSegments": "2",
- "BreakOnNonKeyFrames": true,
"EnableAudioVbrEncoding": true
},
{
@@ -173,8 +172,7 @@
"Context": "Streaming",
"Protocol": "hls",
"MaxAudioChannels": "2",
- "MinSegments": "2",
- "BreakOnNonKeyFrames": true
+ "MinSegments": "2"
},
{
"Container": "ts",
@@ -184,8 +182,7 @@
"Context": "Streaming",
"Protocol": "hls",
"MaxAudioChannels": "2",
- "MinSegments": "2",
- "BreakOnNonKeyFrames": true
+ "MinSegments": "2"
}
],
"ContainerProfiles": [],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json
index 9d43d2166..3625b099c 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen3-stereo.json
@@ -165,7 +165,6 @@
"MaxAudioChannels": "2",
"MinSegments": 1,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": true,
"$type": "TranscodingProfile"
},
{
@@ -182,7 +181,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -199,7 +197,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -216,7 +213,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -233,7 +229,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -250,7 +245,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -267,7 +261,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -284,7 +277,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -301,7 +293,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -319,7 +310,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"Conditions": [
{
"Condition": "LessThanEqual",
@@ -346,7 +336,6 @@
"MaxAudioChannels": "2",
"MinSegments": 1,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"Conditions": [
{
"Condition": "LessThanEqual",
@@ -373,7 +362,6 @@
"MaxAudioChannels": "2",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"Conditions": [
{
"Condition": "LessThanEqual",
@@ -399,7 +387,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"Conditions": [
{
"Condition": "LessThanEqual",
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json
index 3859ef994..deee650b2 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Tizen4-4K-5.1.json
@@ -165,7 +165,6 @@
"MaxAudioChannels": "6",
"MinSegments": 1,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": true,
"$type": "TranscodingProfile"
},
{
@@ -182,7 +181,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -199,7 +197,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -216,7 +213,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -233,7 +229,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -250,7 +245,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -267,7 +261,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -284,7 +277,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -301,7 +293,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -319,7 +310,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"Conditions": [
{
"Condition": "LessThanEqual",
@@ -346,7 +336,6 @@
"MaxAudioChannels": "6",
"MinSegments": 1,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"Conditions": [
{
"Condition": "LessThanEqual",
@@ -373,7 +362,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"Conditions": [
{
"Condition": "LessThanEqual",
@@ -399,7 +387,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"Conditions": [
{
"Condition": "LessThanEqual",
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-TranscodeMedia.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-TranscodeMedia.json
index 9fc1ae6bb..38de51b04 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-TranscodeMedia.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-TranscodeMedia.json
@@ -16,7 +16,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -28,7 +27,6 @@
"Protocol": "hls",
"MaxAudioChannels": "2",
"MinSegments": "2",
- "BreakOnNonKeyFrames": true,
"$type": "TranscodingProfile"
},
{
@@ -40,7 +38,6 @@
"Protocol": "hls",
"MaxAudioChannels": "2",
"MinSegments": "2",
- "BreakOnNonKeyFrames": true,
"$type": "TranscodingProfile"
},
{
@@ -64,7 +61,6 @@
"EnableSubtitlesInManifest": false,
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json
index 094b0723b..3ff11a684 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-WebOS-23.json
@@ -135,7 +135,6 @@
"Protocol": "hls",
"MaxAudioChannels": "6",
"MinSegments": "1",
- "BreakOnNonKeyFrames": false,
"EnableAudioVbrEncoding": true
},
{
@@ -210,8 +209,7 @@
"Context": "Streaming",
"Protocol": "hls",
"MaxAudioChannels": "6",
- "MinSegments": "1",
- "BreakOnNonKeyFrames": false
+ "MinSegments": "1"
}
],
"ContainerProfiles": [],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse.json
index 256c8dc2f..838a1f920 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse.json
@@ -52,7 +52,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -70,7 +69,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -88,7 +86,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse2.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse2.json
index 256c8dc2f..838a1f920 100644
--- a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse2.json
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-Yatse2.json
@@ -52,7 +52,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -70,7 +69,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
},
{
@@ -88,7 +86,6 @@
"MaxAudioChannels": "6",
"MinSegments": 0,
"SegmentLength": 0,
- "BreakOnNonKeyFrames": false,
"$type": "TranscodingProfile"
}
],
diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
index 38208476f..871604514 100644
--- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
+++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
@@ -113,7 +113,7 @@ namespace Jellyfin.Networking.Tests
public void IPv4SubnetMaskMatchesValidIPAddress(string netMask, string ipAddress)
{
var ipa = IPAddress.Parse(ipAddress);
- Assert.True(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress)));
+ Assert.True(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Subnet.Contains(IPAddress.Parse(ipAddress)));
}
/// <summary>
@@ -131,7 +131,7 @@ namespace Jellyfin.Networking.Tests
public void IPv4SubnetMaskDoesNotMatchInvalidIPAddress(string netMask, string ipAddress)
{
var ipa = IPAddress.Parse(ipAddress);
- Assert.False(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress)));
+ Assert.False(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Subnet.Contains(IPAddress.Parse(ipAddress)));
}
/// <summary>
@@ -147,7 +147,7 @@ namespace Jellyfin.Networking.Tests
[InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")]
public void IPv6SubnetMaskMatchesValidIPAddress(string netMask, string ipAddress)
{
- Assert.True(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress)));
+ Assert.True(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Subnet.Contains(IPAddress.Parse(ipAddress)));
}
[Theory]
@@ -158,7 +158,7 @@ namespace Jellyfin.Networking.Tests
[InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0001")]
public void IPv6SubnetMaskDoesNotMatchInvalidIPAddress(string netMask, string ipAddress)
{
- Assert.False(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress)));
+ Assert.False(NetworkUtils.TryParseToSubnet(netMask, out var subnet) && subnet.Subnet.Contains(IPAddress.Parse(ipAddress)));
}
[Theory]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Cryptography/CryptographyProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Cryptography/CryptographyProviderTests.cs
new file mode 100644
index 000000000..052bdf740
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Cryptography/CryptographyProviderTests.cs
@@ -0,0 +1,102 @@
+using System;
+using Emby.Server.Implementations.Cryptography;
+using MediaBrowser.Model.Cryptography;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Cryptography;
+
+public class CryptographyProviderTests
+{
+ private readonly CryptographyProvider _sut = new();
+
+ [Fact]
+ public void CreatePasswordHash_WithPassword_ReturnsHashWithIterations()
+ {
+ var hash = _sut.CreatePasswordHash("testpassword");
+
+ Assert.Equal("PBKDF2-SHA512", hash.Id);
+ Assert.True(hash.Parameters.ContainsKey("iterations"));
+ Assert.NotEmpty(hash.Salt.ToArray());
+ Assert.NotEmpty(hash.Hash.ToArray());
+ }
+
+ [Fact]
+ public void Verify_WithValidPassword_ReturnsTrue()
+ {
+ const string password = "testpassword";
+ var hash = _sut.CreatePasswordHash(password);
+
+ Assert.True(_sut.Verify(hash, password));
+ }
+
+ [Fact]
+ public void Verify_WithWrongPassword_ReturnsFalse()
+ {
+ var hash = _sut.CreatePasswordHash("correctpassword");
+
+ Assert.False(_sut.Verify(hash, "wrongpassword"));
+ }
+
+ [Fact]
+ public void Verify_PBKDF2_MissingIterations_ThrowsFormatException()
+ {
+ var hash = PasswordHash.Parse("$PBKDF2$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
+
+ var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password"));
+ Assert.Contains("missing required 'iterations' parameter", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Verify_PBKDF2SHA512_MissingIterations_ThrowsFormatException()
+ {
+ var hash = PasswordHash.Parse("$PBKDF2-SHA512$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
+
+ var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password"));
+ Assert.Contains("missing required 'iterations' parameter", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Verify_PBKDF2_InvalidIterationsFormat_ThrowsFormatException()
+ {
+ var hash = PasswordHash.Parse("$PBKDF2$iterations=abc$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
+
+ var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password"));
+ Assert.Contains("invalid 'iterations' parameter", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Verify_PBKDF2SHA512_InvalidIterationsFormat_ThrowsFormatException()
+ {
+ var hash = PasswordHash.Parse("$PBKDF2-SHA512$iterations=notanumber$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
+
+ var exception = Assert.Throws<FormatException>(() => _sut.Verify(hash, "password"));
+ Assert.Contains("invalid 'iterations' parameter", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Verify_UnsupportedHashId_ThrowsNotSupportedException()
+ {
+ var hash = PasswordHash.Parse("$UNKNOWN$69F420$62FBA410AFCA5B4475F35137AB2E8596B127E4D927BA23F6CC05C067E897042D");
+
+ Assert.Throws<NotSupportedException>(() => _sut.Verify(hash, "password"));
+ }
+
+ [Fact]
+ public void GenerateSalt_ReturnsNonEmptyArray()
+ {
+ var salt = _sut.GenerateSalt();
+
+ Assert.NotEmpty(salt);
+ }
+
+ [Theory]
+ [InlineData(16)]
+ [InlineData(32)]
+ [InlineData(64)]
+ public void GenerateSalt_WithLength_ReturnsArrayOfSpecifiedLength(int length)
+ {
+ var salt = _sut.GenerateSalt(length);
+
+ Assert.Equal(length, salt.Length);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs
new file mode 100644
index 000000000..c450cbb0e
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryTests.cs
@@ -0,0 +1,72 @@
+using System;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public class BaseItemRepositoryTests
+{
+ [Fact]
+ public void DeserializeBaseItem_WithUnknownType_ReturnsNull()
+ {
+ // Arrange
+ var entity = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "NonExistent.Plugin.CustomItemType"
+ };
+
+ // Act
+ var result = BaseItemRepository.DeserializeBaseItem(entity, NullLogger.Instance, null, false);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void DeserializeBaseItem_WithUnknownType_LogsWarning()
+ {
+ // Arrange
+ var entity = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "NonExistent.Plugin.CustomItemType"
+ };
+ var loggerMock = new Mock<ILogger>();
+
+ // Act
+ BaseItemRepository.DeserializeBaseItem(entity, loggerMock.Object, null, false);
+
+ // Assert
+ loggerMock.Verify(
+ x => x.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("unknown type", StringComparison.OrdinalIgnoreCase)),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+ }
+
+ [Fact]
+ public void DeserializeBaseItem_WithKnownType_ReturnsItem()
+ {
+ // Arrange
+ var entity = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.Movies.Movie"
+ };
+
+ // Act
+ var result = BaseItemRepository.DeserializeBaseItem(entity, NullLogger.Instance, null, false);
+
+ // Assert
+ Assert.NotNull(result);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
index d677c9f09..a7bbef7ed 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
@@ -1,30 +1,81 @@
+using Emby.Server.Implementations.Library;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Library;
public class DotIgnoreIgnoreRuleTest
{
- [Fact]
- public void Test()
+ private static readonly string[] _rule1 = ["SPs"];
+ private static readonly string[] _rule2 = ["SPs", "!thebestshot.mkv"];
+ private static readonly string[] _rule3 = ["*.txt", @"{\colortbl;\red255\green255\blue255;}", "videos/", @"\invalid\escape\sequence", "*.mkv"];
+ private static readonly string[] _rule4 = [@"{\colortbl;\red255\green255\blue255;}", @"\invalid\escape\sequence"];
+
+ public static TheoryData<string[], string, bool, bool> CheckIgnoreRulesTestData =>
+ new()
+ {
+ // Basic pattern matching
+ { _rule1, "f:/cd/sps/ffffff.mkv", false, true },
+ { _rule1, "cd/sps/ffffff.mkv", false, true },
+ { _rule1, "/cd/sps/ffffff.mkv", false, true },
+
+ // Negate pattern
+ { _rule2, "f:/cd/sps/ffffff.mkv", false, true },
+ { _rule2, "cd/sps/ffffff.mkv", false, true },
+ { _rule2, "/cd/sps/ffffff.mkv", false, true },
+ { _rule2, "f:/cd/sps/thebestshot.mkv", false, false },
+ { _rule2, "cd/sps/thebestshot.mkv", false, false },
+ { _rule2, "/cd/sps/thebestshot.mkv", false, false },
+
+ // Mixed valid and invalid patterns - skips invalid, applies valid
+ { _rule3, "test.txt", false, true },
+ { _rule3, "videos/movie.mp4", false, true },
+ { _rule3, "movie.mkv", false, true },
+ { _rule3, "test.mp3", false, false },
+
+ // Only invalid patterns - falls back to ignore all
+ { _rule4, "any-file.txt", false, true },
+ { _rule4, "any/path/to/file.mkv", false, true },
+ };
+
+ public static TheoryData<string[], string, bool, bool> WindowsPathNormalizationTestData =>
+ new()
+ {
+ // Windows paths with backslashes - should match when normalizePath is true
+ { _rule1, @"C:\cd\sps\ffffff.mkv", false, true },
+ { _rule1, @"D:\media\sps\movie.mkv", false, true },
+ { _rule1, @"\\server\share\sps\file.mkv", false, true },
+
+ // Negate pattern with Windows paths
+ { _rule2, @"C:\cd\sps\ffffff.mkv", false, true },
+ { _rule2, @"C:\cd\sps\thebestshot.mkv", false, false },
+
+ // Directory matching with Windows paths
+ { _rule3, @"C:\videos\movie.mp4", false, true },
+ { _rule3, @"D:\documents\test.txt", false, true },
+ { _rule3, @"E:\music\song.mp3", false, false },
+ };
+
+ [Theory]
+ [MemberData(nameof(CheckIgnoreRulesTestData))]
+ public void CheckIgnoreRules_ReturnsExpectedResult(string[] rules, string path, bool isDirectory, bool expectedIgnored)
+ {
+ Assert.Equal(expectedIgnored, DotIgnoreIgnoreRule.CheckIgnoreRules(path, rules, isDirectory));
+ }
+
+ [Theory]
+ [MemberData(nameof(WindowsPathNormalizationTestData))]
+ public void CheckIgnoreRules_WithWindowsPaths_NormalizesBackslashes(string[] rules, string path, bool isDirectory, bool expectedIgnored)
{
- var ignore = new Ignore.Ignore();
- ignore.Add("SPs");
- Assert.True(ignore.IsIgnored("f:/cd/sps/ffffff.mkv"));
- Assert.True(ignore.IsIgnored("cd/sps/ffffff.mkv"));
- Assert.True(ignore.IsIgnored("/cd/sps/ffffff.mkv"));
+ // With normalizePath=true, backslashes should be converted to forward slashes
+ Assert.Equal(expectedIgnored, DotIgnoreIgnoreRule.CheckIgnoreRules(path, rules, isDirectory, normalizePath: true));
}
- [Fact]
- public void TestNegatePattern()
+ [Theory]
+ [InlineData(@"C:\cd\sps\ffffff.mkv")]
+ [InlineData(@"D:\media\sps\movie.mkv")]
+ public void CheckIgnoreRules_WithWindowsPaths_WithoutNormalization_DoesNotMatch(string path)
{
- var ignore = new Ignore.Ignore();
- ignore.Add("SPs");
- ignore.Add("!thebestshot.mkv");
- Assert.True(ignore.IsIgnored("f:/cd/sps/ffffff.mkv"));
- Assert.True(ignore.IsIgnored("cd/sps/ffffff.mkv"));
- Assert.True(ignore.IsIgnored("/cd/sps/ffffff.mkv"));
- Assert.True(!ignore.IsIgnored("f:/cd/sps/thebestshot.mkv"));
- Assert.True(!ignore.IsIgnored("cd/sps/thebestshot.mkv"));
- Assert.True(!ignore.IsIgnored("/cd/sps/thebestshot.mkv"));
+ // Without normalization, Windows paths with backslashes won't match patterns expecting forward slashes
+ Assert.False(DotIgnoreIgnoreRule.CheckIgnoreRules(path, _rule1, isDirectory: false, normalizePath: false));
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
index 940e3c2b1..650d67b19 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
@@ -11,21 +11,29 @@ namespace Jellyfin.Server.Implementations.Tests.Library
[InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son", "imdbid", null)]
- [InlineData("Superman: Red Son", "something", null)]
[InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
- [InlineData("Superman: Red Son [imdbid1-tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")]
- [InlineData("Superman: Red Son [tmdbid-618355][imdbid-tt10985510]", "imdbid", "tt10985510")]
- [InlineData("Superman: Red Son [tmdbid-618355][imdbid-tt10985510]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")]
[InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")]
[InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")]
[InlineData("Superman: Red Son [providera id=4]", "providera id", "4")]
[InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")]
[InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")]
[InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")]
[InlineData("[tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("{tmdbid=618355}", "tmdbid", "618355")]
+ [InlineData("(tmdbid=618355)", "tmdbid", "618355")]
[InlineData("[tmdbid-618355]", "tmdbid", "618355")]
+ [InlineData("{tmdbid-618355)", "tmdbid", null)]
+ [InlineData("[tmdbid-618355}", "tmdbid", null)]
[InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")]
[InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")]
[InlineData("tmdbid=618355]", "tmdbid", null)]
@@ -36,6 +44,9 @@ namespace Jellyfin.Server.Implementations.Tests.Library
[InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)]
[InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)]
[InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")]
+ [InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)]
+ [InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)]
+ [InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")]
public void GetAttributeValue_ValidArgs_Correct(string input, string attribute, string? expectedResult)
{
Assert.Equal(expectedResult, PathExtensions.GetAttributeValue(input, attribute));
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 6d6bba4fc..e60522bf7 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -204,6 +204,25 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Theory]
+ [InlineData("TV-MA", "DE", 17, 1)] // US-only rating, DE country code
+ [InlineData("PG-13", "FR", 13, 0)] // US-only rating, FR country code
+ [InlineData("R", "JP", 17, 0)] // US-only rating, JP country code
+ public async Task GetRatingScore_FallbackPrioritizesUS_Success(string rating, string countryCode, int expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration()
+ {
+ MetadataCountryCode = countryCode
+ });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(rating);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Theory]
[InlineData("Default", "Default")]
[InlineData("HeaderLiveTV", "Live TV")]
public void GetLocalizedString_Valid_Success(string key, string expected)
diff --git a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj
index 8228c0df7..7b0e23788 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj
+++ b/tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj
@@ -5,7 +5,6 @@
<PackageReference Include="AutoFixture.AutoMoq" />
<PackageReference Include="AutoFixture.Xunit2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
- <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
diff --git a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj
index 5fea805ae..21596e0ed 100644
--- a/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj
+++ b/tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj
@@ -5,7 +5,6 @@
<PackageReference Include="AutoFixture.AutoMoq" />
<PackageReference Include="AutoFixture.Xunit2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
- <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio">
diff --git a/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs
index 123266d29..14f4c33b6 100644
--- a/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs
+++ b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs
@@ -11,7 +11,6 @@ using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
-using IPNetwork = Microsoft.AspNetCore.HttpOverrides.IPNetwork;
namespace Jellyfin.Server.Tests
{
@@ -87,7 +86,7 @@ namespace Jellyfin.Server.Tests
// Need this here as ::1 and 127.0.0.1 are in them by default.
options.KnownProxies.Clear();
- options.KnownNetworks.Clear();
+ options.KnownIPNetworks.Clear();
ApiServiceCollectionExtensions.AddProxyAddresses(settings, hostList, options);
@@ -97,10 +96,10 @@ namespace Jellyfin.Server.Tests
Assert.True(options.KnownProxies.Contains(item));
}
- Assert.Equal(knownNetworks.Length, options.KnownNetworks.Count);
+ Assert.Equal(knownNetworks.Length, options.KnownIPNetworks.Count);
foreach (var item in knownNetworks)
{
- Assert.NotNull(options.KnownNetworks.FirstOrDefault(x => x.Prefix.Equals(item.Prefix) && x.PrefixLength == item.PrefixLength));
+ Assert.NotEqual(default, options.KnownIPNetworks.FirstOrDefault(x => x.BaseAddress.Equals(item.BaseAddress) && x.PrefixLength == item.PrefixLength));
}
}