aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.github/workflows/ci-codeql-analysis.yml10
-rw-r--r--.github/workflows/ci-compat.yml8
-rw-r--r--.github/workflows/ci-format.yml4
-rw-r--r--.github/workflows/ci-tests.yml4
-rw-r--r--.github/workflows/commands.yml6
-rw-r--r--.github/workflows/issue-stale.yml2
-rw-r--r--.github/workflows/issue-template-check.yml4
-rw-r--r--.github/workflows/openapi-generate.yml4
-rw-r--r--.github/workflows/openapi-pull-request.yml2
-rw-r--r--.github/workflows/pull-request-stale.yaml2
-rw-r--r--.github/workflows/release-bump-version.yaml4
-rw-r--r--Directory.Packages.props55
-rw-r--r--Emby.Naming/Book/BookFileNameParser.cs29
-rw-r--r--Emby.Naming/Book/BookFileNameParserResult.cs8
-rw-r--r--Emby.Naming/TV/EpisodePathParser.cs2
-rw-r--r--Emby.Naming/TV/SeriesInfo.cs6
-rw-r--r--Emby.Naming/TV/SeriesResolver.cs5
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs11
-rw-r--r--Emby.Server.Implementations/Devices/DeviceId.cs13
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs51
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs6
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs2
-rw-r--r--Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs44
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs59
-rw-r--r--Emby.Server.Implementations/Library/PathExtensions.cs81
-rw-r--r--Emby.Server.Implementations/Library/PathManager.cs15
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs8
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs21
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs12
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs8
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs11
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs219
-rw-r--r--Emby.Server.Implementations/Localization/Core/az.json19
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_419.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json40
-rw-r--r--Emby.Server.Implementations/Localization/Core/gl.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/id.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/is.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/si.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sq.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sw.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json4
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs52
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/gr.json27
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/se.json13
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs3
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs16
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs142
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs60
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs15
-rw-r--r--Emby.Server.Implementations/SyncPlay/Group.cs3
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs92
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs41
-rw-r--r--Jellyfin.Api/Controllers/ActivityLogController.cs31
-rw-r--r--Jellyfin.Api/Controllers/HlsSegmentController.cs42
-rw-r--r--Jellyfin.Api/Controllers/ImageController.cs10
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs9
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs448
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs4
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs10
-rw-r--r--Jellyfin.Api/Controllers/TrailersController.cs1
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs9
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs2
-rw-r--r--Jellyfin.Api/Extensions/DtoExtensions.cs32
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs8
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs9
-rw-r--r--Jellyfin.Server.Implementations/Activity/ActivityManager.cs10
-rw-r--r--Jellyfin.Server.Implementations/Devices/DeviceManager.cs6
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs56
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs17
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs76
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs8
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs7
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs9
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs27
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs1
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs5
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs43
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs43
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs40
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs40
-rw-r--r--Jellyfin.Server/Program.cs7
-rw-r--r--Jellyfin.Server/ServerSetupApp/SetupServer.cs87
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupActivity.cs41
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs109
-rw-r--r--Jellyfin.Server/ServerSetupApp/index.mstemplate.html692
-rw-r--r--MediaBrowser.Controller/Channels/ChannelItemResult.cs14
-rw-r--r--MediaBrowser.Controller/Channels/ChannelItemType.cs11
-rw-r--r--MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs8
-rw-r--r--MediaBrowser.Controller/Channels/ChannelParentalRating.cs20
-rw-r--r--MediaBrowser.Controller/Channels/ChannelSearchInfo.cs11
-rw-r--r--MediaBrowser.Controller/Channels/IHasCacheKey.cs7
-rw-r--r--MediaBrowser.Controller/Channels/ISupportsDelete.cs16
-rw-r--r--MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs5
-rw-r--r--MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs10
-rw-r--r--MediaBrowser.Controller/Dto/DtoOptions.cs7
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs174
-rw-r--r--MediaBrowser.Controller/Entities/Book.cs5
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs17
-rw-r--r--MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs5
-rw-r--r--MediaBrowser.Controller/Entities/IHasStartDate.cs8
-rw-r--r--MediaBrowser.Controller/Entities/IItemByName.cs15
-rw-r--r--MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs5
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs96
-rw-r--r--MediaBrowser.Controller/Entities/Person.cs11
-rw-r--r--MediaBrowser.Controller/Entities/SourceType.cs16
-rw-r--r--MediaBrowser.Controller/Entities/UserRootFolder.cs8
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs14
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs185
-rw-r--r--MediaBrowser.Controller/Library/IUserDataManager.cs26
-rw-r--r--MediaBrowser.Controller/Library/VersionPlaybackSelector.cs59
-rw-r--r--MediaBrowser.Controller/Library/VersionResumeData.cs41
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs94
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs9
-rw-r--r--MediaBrowser.Controller/Providers/BookInfo.cs8
-rw-r--r--MediaBrowser.Controller/Providers/BoxSetInfo.cs5
-rw-r--r--MediaBrowser.Controller/Providers/IHasLookupInfo.cs10
-rw-r--r--MediaBrowser.Controller/Providers/IPreRefreshProvider.cs5
-rw-r--r--MediaBrowser.Controller/Providers/MovieInfo.cs5
-rw-r--r--MediaBrowser.Controller/Providers/PersonLookupInfo.cs5
-rw-r--r--MediaBrowser.Controller/Providers/SeriesInfo.cs5
-rw-r--r--MediaBrowser.Controller/Providers/TrailerInfo.cs5
-rw-r--r--MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs2
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs7
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs47
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs6
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs21
-rw-r--r--MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj1
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs112
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs6
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs20
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs287
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs1
-rw-r--r--MediaBrowser.Model/Configuration/ImageSavingConvention.cs12
-rw-r--r--MediaBrowser.Model/Dlna/CodecType.cs16
-rw-r--r--MediaBrowser.Model/Dlna/EncodingContext.cs12
-rw-r--r--MediaBrowser.Model/Dlna/PlaybackErrorCode.cs16
-rw-r--r--MediaBrowser.Model/Dlna/ResolutionOptions.cs11
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs8
-rw-r--r--MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs12
-rw-r--r--MediaBrowser.Model/Dto/IHasServerId.cs7
-rw-r--r--MediaBrowser.Model/Dto/MediaSourceType.cs16
-rw-r--r--MediaBrowser.Model/Dto/RatingType.cs12
-rw-r--r--MediaBrowser.Model/Library/PlayAccess.cs12
-rw-r--r--MediaBrowser.Model/LiveTv/DayPattern.cs16
-rw-r--r--MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs12
-rw-r--r--MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs16
-rw-r--r--MediaBrowser.Model/Session/MessageCommand.cs13
-rw-r--r--MediaBrowser.Model/Session/PlayMethod.cs16
-rw-r--r--MediaBrowser.Model/Session/PlaystateRequest.cs11
-rw-r--r--MediaBrowser.Model/Session/QueueItem.cs10
-rw-r--r--MediaBrowser.Model/Session/RepeatMode.cs16
-rw-r--r--MediaBrowser.Providers/Books/ComicServiceRegistrator.cs23
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs28
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs4
-rw-r--r--MediaBrowser.Providers/Manager/ImageSaver.cs2
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs23
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj1
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs2
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs54
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs4
-rwxr-xr-x[-rw-r--r--]MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs36
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs13
-rw-r--r--MediaBrowser.Providers/TV/SeriesMetadataService.cs84
-rw-r--r--README.md36
-rw-r--r--src/Jellyfin.Extensions/PathHelper.cs77
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelManager.cs4
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs46
-rw-r--r--src/Jellyfin.LiveTv/IO/EncodedRecorder.cs1
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs22
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs24
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs184
-rw-r--r--src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs33
-rw-r--r--src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs2
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs161
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs129
-rw-r--r--tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs44
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs289
-rw-r--r--tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs89
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs45
-rw-r--r--tests/Jellyfin.Extensions.Tests/PathHelperTests.cs60
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs57
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs59
-rw-r--r--tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs51
-rw-r--r--tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs24
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml17
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml18
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json1
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs22
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs2
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs9
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs110
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs12
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs52
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs24
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs338
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json113
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json110
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json49
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs50
-rw-r--r--tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs58
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs43
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs182
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs6
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs237
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs144
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj1
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs104
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs63
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs209
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs14
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs80
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs24
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs142
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs2
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs29
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs140
234 files changed, 7606 insertions, 1584 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 74e66d3adb..bc574d7054 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "10.0.9",
+ "version": "10.0.10",
"commands": [
"dotnet-ef"
]
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 06a66bab53..a55a400008 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -24,21 +24,21 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup .NET
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+ uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+ uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+ uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml
index a0564027e3..bf28129368 100644
--- a/.github/workflows/ci-compat.yml
+++ b/.github/workflows/ci-compat.yml
@@ -11,13 +11,13 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
@@ -40,14 +40,14 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
diff --git a/.github/workflows/ci-format.yml b/.github/workflows/ci-format.yml
index a9eebf0663..73c8e7e50c 100644
--- a/.github/workflows/ci-format.yml
+++ b/.github/workflows/ci-format.yml
@@ -15,9 +15,9 @@ jobs:
format-check:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 6da1334039..5b29a66382 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -20,9 +20,9 @@ jobs:
runs-on: "${{ matrix.os }}"
steps:
- - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index 43ef0aab37..d14ce6d508 100644
--- a/.github/workflows/commands.yml
+++ b/.github/workflows/commands.yml
@@ -24,7 +24,7 @@ jobs:
reactions: '+1'
- name: Checkout the latest code
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -40,12 +40,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'
diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml
index d6372ef6f4..9adac6b995 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@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
+ - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.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 ef5c7c09f2..3220bcbeac 100644
--- a/.github/workflows/issue-template-check.yml
+++ b/.github/workflows/issue-template-check.yml
@@ -10,12 +10,12 @@ jobs:
issues: write
steps:
- name: pull in script
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'
diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml
index 122bbd69ac..48bd8adb03 100644
--- a/.github/workflows/openapi-generate.yml
+++ b/.github/workflows/openapi-generate.yml
@@ -22,13 +22,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
repository: ${{ inputs.repository }}
- name: Configure .NET
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
+ uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
diff --git a/.github/workflows/openapi-pull-request.yml b/.github/workflows/openapi-pull-request.yml
index d11b0140f7..9ed1873942 100644
--- a/.github/workflows/openapi-pull-request.yml
+++ b/.github/workflows/openapi-pull-request.yml
@@ -10,7 +10,7 @@ jobs:
base_ref: ${{ steps.ancestor.outputs.base_ref }}
steps:
- name: Checkout Repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml
index 6f225a4714..f92a9be9d5 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@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
+ - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.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 5bb668c89c..4d6e1ad6fa 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.TAG_BRANCH }}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7ab5b5d53a..15f36872e0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -26,33 +26,34 @@
<PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
- <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
- <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
- <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
- <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.9" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
- <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
- <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
+ <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
+ <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
+ <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
<PackageVersion Include="Moq" Version="4.18.4" />
<PackageVersion Include="NEbml" Version="1.1.0.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
+ <PackageVersion Include="PDFtoImage" Version="5.2.1" />
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
@@ -67,17 +68,17 @@
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
- <PackageVersion Include="SharpCompress" Version="0.49.1" />
- <PackageVersion Include="SharpFuzz" Version="2.2.0" />
+ <PackageVersion Include="SharpCompress" Version="0.50.0" />
+ <PackageVersion Include="SharpFuzz" Version="2.3.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
- <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.1" />
- <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.1" />
- <PackageVersion Include="System.Text.Json" Version="10.0.9" />
+ <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
+ <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
+ <PackageVersion Include="System.Text.Json" Version="10.0.10" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
<PackageVersion Include="TMDbLib" Version="3.0.0" />
diff --git a/Emby.Naming/Book/BookFileNameParser.cs b/Emby.Naming/Book/BookFileNameParser.cs
index 28625f16de..080e25969b 100644
--- a/Emby.Naming/Book/BookFileNameParser.cs
+++ b/Emby.Naming/Book/BookFileNameParser.cs
@@ -1,3 +1,4 @@
+using System;
using System.Text.RegularExpressions;
namespace Emby.Naming.Book
@@ -5,7 +6,7 @@ namespace Emby.Naming.Book
/// <summary>
/// Helper class to retrieve basic metadata from a book filename.
/// </summary>
- public static class BookFileNameParser
+ public static partial class BookFileNameParser
{
private const string NameMatchGroup = "name";
private const string IndexMatchGroup = "index";
@@ -15,14 +16,17 @@ namespace Emby.Naming.Book
private static readonly Regex[] _nameMatches =
[
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required
- new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
- new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"),
- new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
+ new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)(?:\.0)?((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
+ new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)(?:\.0)?((\s\((?<year>[0-9]{4})\))?)$"),
+ new Regex(@"^(?<index>[0-9]+)(?:\.0)?\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
// last resort matches the whole string as the name
new Regex(@"(?<name>.*)")
];
+ [GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")]
+ private static partial Regex ComicRegex();
+
/// <summary>
/// Parse a filename name to retrieve the book name, series name, index, and year.
/// </summary>
@@ -48,7 +52,22 @@ namespace Emby.Naming.Book
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
{
- result.Name = nameGroup.Value.Trim();
+ var comicMatch = ComicRegex().Match(nameGroup.Value.Trim());
+
+ if (comicMatch.Success)
+ {
+ if (comicMatch.Groups.TryGetValue("volume", out Group? volumeGroup) && volumeGroup.Success && int.TryParse(volumeGroup.ValueSpan, out var volume))
+ {
+ result.ParentIndex = volume;
+ }
+
+ if (comicMatch.Groups.TryGetValue("chapter", out Group? chapterGroup) && chapterGroup.Success && int.TryParse(chapterGroup.ValueSpan, out var chapter))
+ {
+ result.Index = chapter;
+ }
+ }
+
+ result.Name = nameGroup.ValueSpan.Trim().ToString();
}
if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index))
diff --git a/Emby.Naming/Book/BookFileNameParserResult.cs b/Emby.Naming/Book/BookFileNameParserResult.cs
index f29716b9e3..f313b202c5 100644
--- a/Emby.Naming/Book/BookFileNameParserResult.cs
+++ b/Emby.Naming/Book/BookFileNameParserResult.cs
@@ -1,5 +1,3 @@
-using System;
-
namespace Emby.Naming.Book
{
/// <summary>
@@ -14,6 +12,7 @@ namespace Emby.Naming.Book
{
Name = null;
Index = null;
+ ParentIndex = null;
Year = null;
SeriesName = null;
}
@@ -29,6 +28,11 @@ namespace Emby.Naming.Book
public int? Index { get; set; }
/// <summary>
+ /// Gets or sets the parent index number.
+ /// </summary>
+ public int? ParentIndex { get; set; }
+
+ /// <summary>
/// Gets or sets the publication year.
/// </summary>
public int? Year { get; set; }
diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs
index 8cd5a126e0..0c737964b4 100644
--- a/Emby.Naming/TV/EpisodePathParser.cs
+++ b/Emby.Naming/TV/EpisodePathParser.cs
@@ -125,7 +125,7 @@ namespace Emby.Naming.TV
result.Success = true;
}
}
- else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date))
+ else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date))
{
result.Year = date.Year;
result.Month = date.Month;
diff --git a/Emby.Naming/TV/SeriesInfo.cs b/Emby.Naming/TV/SeriesInfo.cs
index 5d6cb4bd37..e145ff8003 100644
--- a/Emby.Naming/TV/SeriesInfo.cs
+++ b/Emby.Naming/TV/SeriesInfo.cs
@@ -25,5 +25,11 @@ namespace Emby.Naming.TV
/// </summary>
/// <value>The name of the series.</value>
public string? Name { get; set; }
+
+ /// <summary>
+ /// Gets or sets the year of the series.
+ /// </summary>
+ /// <value>The year of the series.</value>
+ public int? Year { get; set; }
}
}
diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs
index 0b7309bae0..733e2418c2 100644
--- a/Emby.Naming/TV/SeriesResolver.cs
+++ b/Emby.Naming/TV/SeriesResolver.cs
@@ -21,7 +21,7 @@ namespace Emby.Naming.TV
/// Regex that matches titles with year in parentheses. Captures the title (which may be
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
/// </summary>
- [GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")]
+ [GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")]
private static partial Regex TitleWithYearRegex();
/// <summary>
@@ -43,7 +43,8 @@ namespace Emby.Naming.TV
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
return new SeriesInfo(path)
{
- Name = seriesName
+ Name = seriesName,
+ Year = int.TryParse(titleWithYearMatch.Groups["year"].ValueSpan, out var year) ? year : null
};
}
}
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 14380c33bf..69e23bcb63 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -93,6 +93,9 @@ using MediaBrowser.Model.Net;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.System;
using MediaBrowser.Model.Tasks;
+using MediaBrowser.Providers.Books;
+using MediaBrowser.Providers.Books.ComicBookInfo;
+using MediaBrowser.Providers.Books.ComicInfo;
using MediaBrowser.Providers.Lyric;
using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Plugins.ListenBrainz;
@@ -496,6 +499,14 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
+ // register the generic local metadata provider for comic files
+ serviceCollection.AddSingleton<ComicProvider>();
+
+ // register the actual implementations of the local metadata provider for comic files
+ serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
+ serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
+ serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
+
serviceCollection.AddSingleton(NetManager);
serviceCollection.AddSingleton<ITaskManager, TaskManager>();
diff --git a/Emby.Server.Implementations/Devices/DeviceId.cs b/Emby.Server.Implementations/Devices/DeviceId.cs
index 0b3c3bbd4f..4929568935 100644
--- a/Emby.Server.Implementations/Devices/DeviceId.cs
+++ b/Emby.Server.Implementations/Devices/DeviceId.cs
@@ -1,5 +1,3 @@
-#pragma warning disable CS1591
-
using System;
using System.Globalization;
using System.IO;
@@ -10,6 +8,9 @@ using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Devices
{
+ /// <summary>
+ /// Provides the persistent unique identifier of this server installation.
+ /// </summary>
public class DeviceId
{
private readonly IApplicationPaths _appPaths;
@@ -18,12 +19,20 @@ namespace Emby.Server.Implementations.Devices
private string? _id;
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DeviceId"/> class.
+ /// </summary>
+ /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{DeviceId}"/> interface.</param>
public DeviceId(IApplicationPaths appPaths, ILogger<DeviceId> logger)
{
_appPaths = appPaths;
_logger = logger;
}
+ /// <summary>
+ /// Gets the device id, loading it from disk or generating and persisting a new one if none exists.
+ /// </summary>
public string Value => _id ??= GetDeviceId();
private string CachePath => Path.Combine(_appPaths.DataPath, "device.txt");
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 3cd72a8ac1..8cbf42585d 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto
{
BaseItemKind.Person, [
BaseItemKind.Audio,
+ BaseItemKind.AudioBook,
+ BaseItemKind.Book,
BaseItemKind.Episode,
BaseItemKind.Movie,
BaseItemKind.LiveTvProgram,
@@ -167,9 +169,13 @@ namespace Emby.Server.Implementations.Dto
// Batch-fetch user data for all items
Dictionary<Guid, UserItemData>? userDataBatch = null;
+ IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null;
if (user is not null && options.EnableUserData)
{
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
+
+ // For items with alternate versions, the most recently played version drives resume.
+ resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user);
}
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
@@ -248,7 +254,8 @@ namespace Emby.Server.Implementations.Dto
allCollectionFolders,
childCountBatch,
playedCountBatch,
- artistsBatch);
+ artistsBatch,
+ resumeDataBatch?.GetValueOrDefault(item.Id));
if (item is LiveTvChannel tvChannel)
{
@@ -309,7 +316,8 @@ namespace Emby.Server.Implementations.Dto
List<Folder>? allCollectionFolders = null,
Dictionary<Guid, int>? childCountBatch = null,
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
- IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
+ IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
+ VersionResumeData? resumeData = null)
{
var dto = new BaseItemDto
{
@@ -353,7 +361,8 @@ namespace Emby.Server.Implementations.Dto
options,
userData,
childCountBatch,
- playedCountBatch);
+ playedCountBatch,
+ resumeData);
}
if (item is IHasMediaSources
@@ -369,7 +378,7 @@ namespace Emby.Server.Implementations.Dto
AttachStudios(dto, item);
}
- AttachBasicFields(dto, item, owner, options, artistsBatch);
+ AttachBasicFields(dto, item, owner, options, artistsBatch, user);
if (options.ContainsField(ItemFields.CanDelete))
{
@@ -538,7 +547,8 @@ namespace Emby.Server.Implementations.Dto
DtoOptions options,
UserItemData? userData = null,
Dictionary<Guid, int>? childCountBatch = null,
- Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
+ Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
+ VersionResumeData? resumeData = null)
{
if (item.IsFolder)
{
@@ -600,6 +610,9 @@ namespace Emby.Server.Implementations.Dto
// Use pre-fetched user data
dto.UserData = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
+
+ // For items with alternate versions, the most recently played version drives resume.
+ resumeData?.ApplyTo(dto.UserData);
}
else
{
@@ -943,7 +956,8 @@ namespace Emby.Server.Implementations.Dto
/// <param name="owner">The owner.</param>
/// <param name="options">The options.</param>
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
- private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
+ /// <param name="user">The user, for per-user values such as the accessible media source count.</param>
+ private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null)
{
if (options.ContainsField(ItemFields.DateCreated))
{
@@ -1257,7 +1271,12 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.MediaSourceCount))
{
- var mediaSourceCount = video.MediaSourceCount;
+ // Match the per-user filtering of the media sources: versions the user cannot
+ // access are not selectable, so they must not count towards the badge either.
+ var mediaSourceCount = user is null
+ || (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
+ ? video.MediaSourceCount
+ : video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
if (mediaSourceCount != 1)
{
dto.MediaSourceCount = mediaSourceCount;
@@ -1366,38 +1385,22 @@ namespace Emby.Server.Implementations.Dto
}
}
- if (options.PreferEpisodeParentPoster)
+ if (options.GetImageLimit(ImageType.Primary) > 0)
{
var episodeSeason = episode.Season;
var seasonPrimaryTag = episodeSeason is not null
? GetTagAndFillBlurhash(dto, episodeSeason, ImageType.Primary)
: null;
- BaseItem? posterParent = null;
if (seasonPrimaryTag is not null)
{
dto.ParentPrimaryImageItemId = episodeSeason!.Id;
dto.ParentPrimaryImageTag = seasonPrimaryTag;
- posterParent = episodeSeason;
}
else if (episodeSeries is not null && dto.SeriesPrimaryImageTag is not null)
{
dto.ParentPrimaryImageItemId = episodeSeries.Id;
dto.ParentPrimaryImageTag = dto.SeriesPrimaryImageTag;
- posterParent = episodeSeries;
- }
-
- if (posterParent is not null)
- {
- if (dto.ImageTags is not null && dto.ImageTags.Remove(ImageType.Primary, out var ownPrimaryTag))
- {
- // Only drop the episode's own primary blurhash; keep the poster parent's.
- dto.ImageBlurHashes?.GetValueOrDefault(ImageType.Primary)?.Remove(ownPrimaryTag);
- }
-
- dto.SeriesPrimaryImageTag = null;
- dto.PrimaryImageAspectRatio = null;
- AttachPrimaryImageAspectRatio(dto, posterParent);
}
}
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index e9bf3b93a7..dc7f972c13 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
@@ -127,8 +127,12 @@ namespace Emby.Server.Implementations.HttpServer
{
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
}
- catch (WebSocketException ex)
+ catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException)
{
+ // ObjectDisposedException/OperationCanceledException: the socket was torn
+ // down underneath us (e.g. by the keep-alive watchdog after the connection
+ // was declared lost). Fall through so Closed is still raised and the
+ // session can release this connection.
_logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
break;
}
diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
index 199407044b..ede9b27592 100644
--- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs
+++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
@@ -691,7 +691,7 @@ namespace Emby.Server.Implementations.IO
}
catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException)
{
- _logger.LogError(ex, "Failed to enumerate path {Path}", path);
+ _logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message);
return Enumerable.Empty<string>();
}
}
diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
index 095934f896..7cae2a671b 100644
--- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common.Configuration;
@@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Images
{
@@ -28,51 +28,23 @@ namespace Emby.Server.Implementations.Images
{
var view = (CollectionFolder)item;
var viewType = view.CollectionType;
+ var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
+ var recursive = viewType != CollectionType.playlists;
- BaseItemKind[] includeItemTypes;
-
- switch (viewType)
+ if (viewType == CollectionType.music)
{
- case CollectionType.movies:
- includeItemTypes = new[] { BaseItemKind.Movie };
- break;
- case CollectionType.tvshows:
- includeItemTypes = new[] { BaseItemKind.Series };
- break;
- case CollectionType.music:
- includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
- break;
- case CollectionType.musicvideos:
- includeItemTypes = new[] { BaseItemKind.MusicVideo };
- break;
- case CollectionType.books:
- includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
- break;
- case CollectionType.boxsets:
- includeItemTypes = new[] { BaseItemKind.BoxSet };
- break;
- case CollectionType.homevideos:
- case CollectionType.photos:
- includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo };
- break;
- default:
- includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series };
- break;
+ // Music albums usually don't have dedicated backdrops, so use artist instead
+ includeItemTypes = [BaseItemKind.MusicArtist];
}
- var recursive = viewType != CollectionType.playlists;
-
return view.GetItemList(new InternalItemsQuery
{
CollapseBoxSetItems = false,
Recursive = recursive,
DtoOptions = new DtoOptions(false),
- ImageTypes = new[] { ImageType.Primary },
+ ImageTypes = [ImageType.Primary],
Limit = 8,
- OrderBy = new[]
- {
- (ItemSortBy.Random, SortOrder.Ascending)
- },
+ OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)],
IncludeItemTypes = includeItemTypes
});
}
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index c369fb0957..97e00177b6 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -229,7 +229,11 @@ namespace Emby.Server.Implementations.Library
list.Add(source);
}
- return SortMediaSources(list, item.Id).ToArray();
+ var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId)
+ ? topSourceId
+ : item.Id;
+
+ return SortMediaSources(list, preferredId).ToArray();
}
/// <inheritdoc />>
@@ -406,6 +410,59 @@ namespace Emby.Server.Implementations.Library
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing);
}
}
+
+ sources = SetAlternateVersionResumeStates(item, sources, user);
+ }
+
+ return sources;
+ }
+
+ /// <summary>
+ /// When the queried item is a primary, moves the most recently played version to the front so
+ /// that resuming without an explicit source selection plays the version that was last watched.
+ /// A directly queried alternate version keeps its own source first. Per-user playback position
+ /// is not surfaced on the source itself; it is carried by each version's own UserData.
+ /// </summary>
+ /// <param name="item">The queried item.</param>
+ /// <param name="sources">The item's media sources.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>The media sources, reordered when a version drives resume.</returns>
+ private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user)
+ {
+ // For a video, multiple sources means alternate versions.
+ if (item is not Video video || sources.Count < 2)
+ {
+ return sources;
+ }
+
+ var versions = video.GetAllVersions();
+ if (versions.Count < 2)
+ {
+ return sources;
+ }
+
+ var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
+ var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase);
+ foreach (var version in versions)
+ {
+ if (userDataByVersion.TryGetValue(version.Id, out var data))
+ {
+ dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data;
+ }
+ }
+
+ // Reorder only for a resumable (in-progress) version;
+ // a completed version has no position to resume, so it must not be pulled to the front here.
+ var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
+ sources,
+ source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null,
+ data => data.PlaybackPositionTicks > 0);
+
+ if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource))
+ {
+ var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource };
+ reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource)));
+ return reordered;
}
return sources;
diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs
index 7591359ea4..7d0f3900c5 100644
--- a/Emby.Server.Implementations/Library/PathExtensions.cs
+++ b/Emby.Server.Implementations/Library/PathExtensions.cs
@@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library
throw new ArgumentException("String can't be empty.", nameof(attribute));
}
- var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
+ // Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc.
+ // The code below only supports aliases for attributes in the form of "<alias>id".
+ ReadOnlySpan<char> shortAttr = attribute switch
+ {
+ _ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
+ _ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
+ _ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
+ _ => ReadOnlySpan<char>.Empty
+ };
- // Must be at least 3 characters after the attribute =, ], any character,
- // then we offset it by 1, because we want the index and not length.
- var maxIndex = str.Length - attribute.Length - 2;
- while (attributeIndex > -1 && attributeIndex < maxIndex)
+ for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
{
- var attributeEnd = attributeIndex + attribute.Length;
+ // We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'.
+ var subStr = str[strIndex..];
+ int attributeEnd = 0;
+
+ if (shortAttr.Length > 0)
+ {
+ // If we are using an alias it should be shorter (and a prefix), so let's search for that.
+ attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
+ attributeEnd = attributeIndex + shortAttr.Length;
+ }
+ else
+ {
+ attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
+ attributeEnd = attributeIndex + attribute.Length;
+ }
+
+ // The next iteration should start at the end of the attribute we just found.
+ // If attributeIndex < 0, the loop will end and strIndex won't be used again.
+ strIndex += attributeEnd;
+
if (attributeIndex > 0)
{
- var attributeOpener = str[attributeIndex - 1];
+ var attributeOpener = subStr[attributeIndex - 1];
var attributeCloser = attributeOpener switch
{
'[' => ']',
@@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library
'{' => '}',
_ => '\0'
};
- if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
+
+ if (attributeCloser != '\0')
{
- var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
+ if (shortAttr.Length > 0
+ && attributeEnd + 1 < subStr.Length
+ && (subStr[attributeEnd] is 'i' or 'I')
+ && (subStr[attributeEnd + 1] is 'd' or 'D'))
+ {
+ // We were searching for a shortened attribute, but it's followed by "id" - let's skip it.
+ attributeEnd += 2;
+ }
- // Must be at least 1 character before the closing bracket.
- if (closingIndex > 1)
+ // attributeEnd points at '='.
+ // We need at least 1 more character and the closing bracket after that.
+ if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-'))
{
- return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
+ var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser);
+
+ // Must be at least 1 character before the closing bracket.
+ if (closingIndex > 1)
+ {
+ var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim();
+
+ if (trimmed.Length > 0)
+ {
+ return trimmed.ToString();
+ }
+ }
}
}
}
-
- str = str[attributeEnd..];
- attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
}
// for imdbid we also accept pattern matching
@@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library
return match ? imdbId.ToString() : null;
}
- // Allow tmdb as an alias for tmdbid
- if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase))
- {
- var tmdbValue = str.GetAttributeValue("tmdb");
- if (tmdbValue is not null)
- {
- return tmdbValue;
- }
- }
-
return null;
}
diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs
index fad948ad97..2a50fcc7fe 100644
--- a/Emby.Server.Implementations/Library/PathManager.cs
+++ b/Emby.Server.Implementations/Library/PathManager.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@@ -43,7 +44,19 @@ public class PathManager : IPathManager
public string? GetAttachmentPath(string mediaSourceId, string fileName)
{
var folder = GetAttachmentFolderPath(mediaSourceId);
- return folder is null ? null : Path.Combine(folder, fileName);
+ if (folder is null)
+ {
+ return null;
+ }
+
+ var safeName = PathHelper.GetSafeLeafFileName(fileName);
+ if (safeName is null)
+ {
+ _logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
+ return null;
+ }
+
+ return Path.Combine(folder, safeName);
}
/// <inheritdoc />
diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
index 1e885aad6e..7d51a0daa0 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs
@@ -1,5 +1,3 @@
-#nullable disable
-
#pragma warning disable CS1591
using System;
@@ -18,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
{
private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" };
- protected override Book Resolve(ItemResolveArgs args)
+ protected override Book? Resolve(ItemResolveArgs args)
{
var collectionType = args.GetCollectionType();
@@ -47,13 +45,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
Path = args.Path,
Name = result.Name ?? string.Empty,
IndexNumber = result.Index,
+ ParentIndexNumber = result.ParentIndex,
ProductionYear = result.Year,
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
IsInMixedFolder = true,
};
}
- private Book GetBook(ItemResolveArgs args)
+ private Book? GetBook(ItemResolveArgs args)
{
var bookFiles = args.FileSystemChildren.Where(f =>
{
@@ -78,6 +77,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
Path = bookFiles[0].FullName,
Name = result.Name ?? string.Empty,
IndexNumber = result.Index,
+ ParentIndexNumber = result.ParentIndex,
ProductionYear = result.Year,
SeriesName = result.SeriesName ?? string.Empty,
};
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
index 68b66ab7f5..80375ae12d 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
@@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
// We need to only look at the name of this actual item (not parents)
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
- var tmdbid = justName.GetAttributeValue("tmdbid");
+ // The fallback filename is only used when the item isn't in a mixed folder
+ var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan());
- // If not in a mixed folder and ID not found in folder path, check filename
- if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
+ item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
+ item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid"));
+
+ string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute)
{
- tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
- }
+ var id = name.GetAttributeValue(attribute);
+
+ // If not in a mixed folder and ID not found in folder path, check filename
+ if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder)
+ {
+ id = fallbackName.GetAttributeValue(attribute);
+ }
- item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
+ return id;
+ }
if (!string.IsNullOrEmpty(item.Path))
{
diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
index 14798dda65..74c1f69616 100644
--- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
@@ -1,6 +1,7 @@
#nullable disable
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using Jellyfin.Data.Enums;
@@ -46,7 +47,16 @@ namespace Emby.Server.Implementations.Library.Resolvers
}
// It's a directory-based playlist if the directory contains a playlist file
- var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
+ IEnumerable<string> filePaths;
+ try
+ {
+ filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
+ }
+ catch (IOException)
+ {
+ return null;
+ }
+
if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
{
return new Playlist
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
index 769d721665..8d40eab006 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeriesResolver.cs
@@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return null;
}
+ if (args.Parent is not null && args.Parent.IsRoot)
+ {
+ return null;
+ }
+
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
var collectionType = args.GetCollectionType();
@@ -69,7 +74,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return new Series
{
Path = args.Path,
- Name = seriesInfo.Name
+ Name = seriesInfo.Name,
+ ProductionYear = seriesInfo.Year
};
}
}
diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
index b4ed12a20c..57d1f7c770 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
@@ -53,6 +53,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IItemQueryHelpers _queryHelpers;
private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="MovieSimilarItemsProvider"/> class.
@@ -60,14 +61,17 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
/// <param name="dbProvider">The database context factory.</param>
/// <param name="queryHelpers">The shared query helpers.</param>
/// <param name="serverConfigurationManager">The server configuration manager.</param>
+ /// <param name="libraryManager">The library manager.</param>
public MovieSimilarItemsProvider(
IDbContextFactory<JellyfinDbContext> dbProvider,
IItemQueryHelpers queryHelpers,
- IServerConfigurationManager serverConfigurationManager)
+ IServerConfigurationManager serverConfigurationManager,
+ ILibraryManager libraryManager)
{
_dbProvider = dbProvider;
_queryHelpers = queryHelpers;
_serverConfigurationManager = serverConfigurationManager;
+ _libraryManager = libraryManager;
}
/// <inheritdoc/>
@@ -156,6 +160,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
IsPlayed = false
};
+ if (query.User is not null)
+ {
+ _libraryManager.ConfigureUserAccess(filter, query.User);
+ }
+
_queryHelpers.PrepareFilterQuery(filter);
var baseQuery = _queryHelpers.PrepareItemQuery(context, filter);
baseQuery = _queryHelpers.TranslateQuery(baseQuery, context, filter);
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 1281f1587f..f5c41e5670 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library
}
else
{
- var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault();
+ var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
+ var userData = userDataRow is not null ? Map(userDataRow) : null;
if (userData is not null)
{
result[item.Id] = userData;
@@ -211,36 +212,128 @@ namespace Emby.Server.Implementations.Library
return result;
}
- // Build a single query for all missing items
+ // Build a single query for all missing items. Fetch rows by item alone so rows kept
+ // under keys from older metadata resolve the same way as the in-memory path.
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
- var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
- if (allKeys.Count > 0)
- {
- using var context = _repository.CreateDbContext();
- var userDataArray = context.UserData
- .AsNoTracking()
- .Where(e => e.UserId.Equals(user.Id))
- .WhereOneOrMany(allItemIds, e => e.ItemId)
- .WhereOneOrMany(allKeys, e => e.CustomDataKey)
- .ToArray();
-
- var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
- foreach (var (item, keys) in itemsNeedingQuery)
+ using var context = _repository.CreateDbContext();
+ var userDataArray = context.UserData
+ .AsNoTracking()
+ .Where(e => e.UserId.Equals(user.Id))
+ .WhereOneOrMany(allItemIds, e => e.ItemId)
+ .ToArray();
+
+ var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
+ foreach (var (item, keys) in itemsNeedingQuery)
+ {
+ UserItemData userData;
+ if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
{
- UserItemData userData;
- if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
- {
- var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N"));
- userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First());
- }
- else
- {
- userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
- }
+ userData = Map(ResolveUserDataRow(item, itemUserData)!);
+ }
+ else
+ {
+ userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
+ }
+
+ result[item.Id] = userData;
+ var cacheKey = GetCacheKey(user.InternalId, item.Id);
+ _cache.AddOrUpdate(cacheKey, userData);
+ }
+
+ return result;
+ }
+
+ /// <inheritdoc />
+ public VersionResumeData? GetResumeUserData(User user, BaseItem item)
+ {
+ return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id);
+ }
+
+ /// <inheritdoc />
+ public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ var result = new Dictionary<Guid, VersionResumeData>();
+
+ // Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data.
+ // Linked alternates are already known in memory; only the local-alternate existence check
+ // would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions),
+ // so collect those ids and resolve them all in a single query below.
+ List<Video>? candidates = null;
+ List<Guid>? localProbeIds = null;
+ foreach (var item in items)
+ {
+ if (item is not Video video || video.PrimaryVersionId.HasValue)
+ {
+ continue;
+ }
+
+ (candidates ??= []).Add(video);
+
+ if (video.LinkedAlternateVersions.Length == 0)
+ {
+ (localProbeIds ??= []).Add(video.Id);
+ }
+ }
+
+ if (candidates is null)
+ {
+ return result;
+ }
+
+ HashSet<Guid>? withLocalAlternates = null;
+ if (localProbeIds is not null)
+ {
+ using var dbContext = _repository.CreateDbContext();
+ withLocalAlternates = dbContext.LinkedChildren
+ .Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion)
+ .WhereOneOrMany(localProbeIds, lc => lc.ParentId)
+ .Select(lc => lc.ParentId)
+ .Distinct()
+ .ToHashSet();
+ }
+
+ List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null;
+ List<BaseItem>? allVersions = null;
+
+ foreach (var video in candidates)
+ {
+ // Only items that actually have alternate versions aggregate over them.
+ if (video.LinkedAlternateVersions.Length == 0
+ && (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id)))
+ {
+ continue;
+ }
+
+ var versions = video.GetAllVersions();
+ if (versions.Count < 2)
+ {
+ continue;
+ }
+
+ (versionGroups ??= []).Add((video.Id, versions));
+ (allVersions ??= []).AddRange(versions);
+ }
+
+ if (versionGroups is null)
+ {
+ return result;
+ }
+
+ var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user);
- result[item.Id] = userData;
- var cacheKey = GetCacheKey(user.InternalId, item.Id);
- _cache.AddOrUpdate(cacheKey, userData);
+ foreach (var (primaryId, versions) in versionGroups)
+ {
+ // Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
+ var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
+ versions,
+ version => userDataByVersion.GetValueOrDefault(version.Id),
+ data => data.PlaybackPositionTicks > 0 || data.Played);
+
+ if (resumeVersion is not null)
+ {
+ result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]);
}
}
@@ -259,12 +352,40 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item)
{
- return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
+ var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
+ return row is not null ? Map(row) : new UserItemData()
{
Key = item.GetUserDataKeys()[0],
};
}
+ /// <summary>
+ /// Picks the row matching the item's current user data keys, in key order, so rows left behind
+ /// under keys from older metadata don't take priority over the rows the write path updates.
+ /// </summary>
+ /// <param name="item">The item whose keys to match.</param>
+ /// <param name="rows">The candidate user data rows for a single user.</param>
+ /// <returns>The best matching row, or <c>null</c> when there are none.</returns>
+ private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows)
+ {
+ var candidates = rows?.ToList();
+ if (candidates is null || candidates.Count == 0)
+ {
+ return null;
+ }
+
+ foreach (var key in item.GetUserDataKeys())
+ {
+ var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal));
+ if (match is not null)
+ {
+ return match;
+ }
+ }
+
+ return candidates[0];
+ }
+
/// <inheritdoc />
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
=> GetUserDataDto(item, null, user, new DtoOptions());
@@ -281,6 +402,10 @@ namespace Emby.Server.Implementations.Library
var dto = GetUserItemDataDto(userData, item.Id);
item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
+
+ // For an item with alternate versions, surface the most recently played version's resume point.
+ GetResumeUserData(user, item)?.ApplyTo(dto);
+
return dto;
}
@@ -385,5 +510,41 @@ namespace Emby.Server.Implementations.Library
return playedToCompletion;
}
+
+ /// <inheritdoc />
+ public void ResetPlaybackStreamSelections(User user, BaseItem item)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentNullException.ThrowIfNull(item);
+
+ using var dbContext = _repository.CreateDbContext();
+ var rows = dbContext.UserData
+ .Where(e => e.ItemId == item.Id && e.UserId == user.Id
+ && (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null))
+ .ToList();
+
+ if (rows.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var row in rows)
+ {
+ row.AudioStreamIndex = null;
+ row.SubtitleStreamIndex = null;
+ }
+
+ dbContext.SaveChanges();
+
+ var cacheKey = GetCacheKey(user.InternalId, item.Id);
+ if (_cache.TryGet(cacheKey, out var cached))
+ {
+ cached.AudioStreamIndex = null;
+ cached.SubtitleStreamIndex = null;
+ _cache.AddOrUpdate(cacheKey, cached);
+ }
+
+ item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
+ }
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/az.json b/Emby.Server.Implementations/Localization/Core/az.json
new file mode 100644
index 0000000000..6ab18c8534
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/az.json
@@ -0,0 +1,19 @@
+{
+ "Books": "Kitablar",
+ "HomeVideos": "Ev Videoları",
+ "Latest": "Ən son",
+ "MixedContent": "Qarışıq məzmun",
+ "Movies": "Filmlər",
+ "Music": "Musiqi",
+ "MusicVideos": "Musiqi Videoları",
+ "NameSeasonUnknown": "Mövsüm Naməlum",
+ "NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.",
+ "NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur",
+ "NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb",
+ "NotificationOptionAudioPlayback": "Audio oxutma başladı",
+ "NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı",
+ "NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi",
+ "NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu",
+ "NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi",
+ "NotificationOptionPluginError": "Plugin uğursuzluğu"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index 697d9c090f..de56b6fd66 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -9,7 +9,7 @@
"Favorites": "Favoritter",
"Folders": "Mapper",
"Genres": "Genrer",
- "HeaderContinueWatching": "Fortsæt afspilning",
+ "HeaderContinueWatching": "Fortsæt med at se",
"HeaderFavoriteEpisodes": "Yndlingsafsnit",
"HeaderFavoriteShows": "Yndlingsserier",
"HeaderLiveTV": "Live-TV",
@@ -65,7 +65,7 @@
"TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.",
"TaskDownloadMissingSubtitles": "Hent manglende undertekster",
"TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.",
- "TaskUpdatePlugins": "Opdater plugins",
+ "TaskUpdatePlugins": "Opdatér plugins",
"TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.",
"TaskCleanLogs": "Ryd log-mappe",
"TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.",
@@ -79,10 +79,10 @@
"TaskRefreshChapterImages": "Udtræk kapitelbilleder",
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
- "TaskRefreshChannels": "Opdater kanaler",
+ "TaskRefreshChannels": "Opdatér kanaler",
"TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
"TaskCleanTranscode": "Tøm omkodningsmappen",
- "TaskRefreshPeople": "Opdater personer",
+ "TaskRefreshPeople": "Opdatér personer",
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
"TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.",
"TaskCleanActivityLog": "Ryd aktivitetslog",
@@ -90,7 +90,7 @@
"Forced": "Tvunget",
"Default": "Standard",
"TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.",
- "TaskOptimizeDatabase": "Optimer database",
+ "TaskOptimizeDatabase": "Optimér database",
"TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.",
"TaskKeyframeExtractor": "Udtræk nøglerammer",
"External": "Ekstern",
@@ -99,7 +99,7 @@
"TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.",
"TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.",
"TaskAudioNormalization": "Lydnormalisering",
- "TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen",
+ "TaskDownloadMissingLyricsDescription": "Download sangtekster",
"TaskDownloadMissingLyrics": "Hent manglende sangtekster",
"TaskExtractMediaSegments": "Scan for mediesegmenter",
"TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder",
diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json
index 1f13451060..4404354a88 100644
--- a/Emby.Server.Implementations/Localization/Core/es_419.json
+++ b/Emby.Server.Implementations/Localization/Core/es_419.json
@@ -106,5 +106,7 @@
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
"CleanupUserDataTask": "Tarea de limpieza de datos de usuario",
- "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días."
+ "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
+ "LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}",
+ "Original": "Original"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 4fb9f4329c..8605a752db 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -1,7 +1,7 @@
{
"Artists": "Listafólk",
"Collections": "Søvn",
- "Default": "Sjálvgildi",
+ "Default": "Forsett",
"External": "Ytri",
"Genres": "Greinar",
"AppDeviceValues": "App: {0}, Eind: {1}",
@@ -12,5 +12,41 @@
"Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar",
- "LabelIpAddressValue": "IP atsetur: {0}"
+ "LabelIpAddressValue": "IP-atsetur: {0}",
+ "AuthenticationSucceededWithUserName": "{0} varð samgildur",
+ "HeaderFavoriteShows": "Yndisrøðir",
+ "HeaderLiveTV": "Beinleiðis sjónvarp",
+ "HearingImpaired": "Hoyrnarveik",
+ "Inherit": "Arvar",
+ "LabelRunningTimeValue": "Spælitíð: {0}",
+ "Latest": "Seinastu",
+ "LyricDownloadFailureFromForItem": "Miseydnaðist at niðurtakað sangtekst fyri {1} frá {0}",
+ "NameInstallFailed": "{0} innlegging miseydnaðist",
+ "NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
+ "NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
+ "NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
+ "NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
+ "NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
+ "NotificationOptionUserLockedOut": "Brúkari útihýstur",
+ "Photos": "Ljósmyndir",
+ "PluginInstalledWithName": "{0} var innlagt",
+ "PluginUninstalledWithName": "{0} var strikað",
+ "PluginUpdatedWithName": "{0} varð dagført",
+ "Shows": "Røðir",
+ "SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}",
+ "TvShows": "Sjónvarpsrøðir",
+ "UserCreatedWithName": "Brúkari {0} er stovnaður",
+ "UserDeletedWithName": "Brúkari {0} er strikaður",
+ "UserDownloadingItemWithValues": "{0} niðurtekur {1}",
+ "UserLockedOutWithName": "Brúkari {0} er útihýstur",
+ "VersionNumber": "Útgáva {0}",
+ "TasksLibraryCategory": "Savn",
+ "TaskRefreshLibrary": "Skanna miðlasavn",
+ "TaskCleanLogsDescription": "Strikar gerðalistafílur eldri enn {0} dagar.",
+ "TaskUpdatePlugins": "Dagfør ískoytisforrit",
+ "TaskRefreshChannels": "Endurinnles rásir",
+ "TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir",
+ "Movies": "Filmar",
+ "MixedContent": "Blandað innihald",
+ "Music": "Tónleikur"
}
diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json
index d3740130ee..a68db0076a 100644
--- a/Emby.Server.Implementations/Localization/Core/gl.json
+++ b/Emby.Server.Implementations/Localization/Core/gl.json
@@ -106,5 +106,6 @@
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
- "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
+ "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
+ "Original": "Orixinal"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index af34bf092e..48056b0bb8 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -106,5 +106,7 @@
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
- "CleanupUserDataTask": "משימת ניקוי מידע משתמש"
+ "CleanupUserDataTask": "משימת ניקוי מידע משתמש",
+ "LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
+ "Original": "מקור"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index 8794339fb1..442c26b30b 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -107,5 +107,6 @@
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.",
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.",
- "Original": "Original"
+ "Original": "Original",
+ "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
}
diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json
index 65c03e70f2..3502ec39ad 100644
--- a/Emby.Server.Implementations/Localization/Core/id.json
+++ b/Emby.Server.Implementations/Localization/Core/id.json
@@ -106,5 +106,7 @@
"TaskExtractMediaSegments": "Scan Segmen media",
"TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay",
"TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang",
- "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna"
+ "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna",
+ "LyricDownloadFailureFromForItem": "Lirik gagal di download dari {0} untuk {1}",
+ "Original": "Asli"
}
diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json
index c9ca00afdf..825ad3084e 100644
--- a/Emby.Server.Implementations/Localization/Core/is.json
+++ b/Emby.Server.Implementations/Localization/Core/is.json
@@ -103,5 +103,10 @@
"TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar",
"TaskExtractMediaSegments": "Skönnun efnishluta",
"CleanupUserDataTask": "Hreinsun notendagagna",
- "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga."
+ "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.",
+ "LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}",
+ "Original": "Upprunaleg",
+ "TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt.",
+ "TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir",
+ "TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index 39e5af717c..78b7ec744b 100644
--- a/Emby.Server.Implementations/Localization/Core/ja.json
+++ b/Emby.Server.Implementations/Localization/Core/ja.json
@@ -106,5 +106,7 @@
"TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
- "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。"
+ "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。",
+ "LyricDownloadFailureFromForItem": "歌詞",
+ "Original": "オリジナル"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json
index 5d64405d19..a210125d34 100644
--- a/Emby.Server.Implementations/Localization/Core/ko.json
+++ b/Emby.Server.Implementations/Localization/Core/ko.json
@@ -106,5 +106,7 @@
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
"CleanupUserDataTask": "사용자 데이터 정리 작업",
- "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다."
+ "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
+ "LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
+ "Original": "원본"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index dd482d1e9b..ce7f6d120e 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -107,5 +107,6 @@
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
"CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
"CleanupUserDataTask": "Limpeza de dados de utilizador",
- "Original": "Original"
+ "Original": "Original",
+ "LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/si.json b/Emby.Server.Implementations/Localization/Core/si.json
index 0967ef424b..8efc0d1f2e 100644
--- a/Emby.Server.Implementations/Localization/Core/si.json
+++ b/Emby.Server.Implementations/Localization/Core/si.json
@@ -1 +1,15 @@
-{}
+{
+ "AppDeviceValues": "ඇප්: {0}, උපාංග: {1}",
+ "Artists": "කලාකරුවන්",
+ "AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී",
+ "Books": "පොත්",
+ "ChapterNameValue": "{0} වෙනි පරිච්ඡේදය",
+ "Collections": "සංහිතා",
+ "Default": "පෙරනිමි",
+ "External": "බාහිර",
+ "FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය",
+ "Favorites": "ප්‍රියතමයන්",
+ "Folders": "ෆෝල්ඩර",
+ "Forced": "නියමිත",
+ "Genres": "ප්‍රභේද"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index afea835bd4..7ae8857e5d 100644
--- a/Emby.Server.Implementations/Localization/Core/sk.json
+++ b/Emby.Server.Implementations/Localization/Core/sk.json
@@ -106,5 +106,7 @@
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
"CleanupUserDataTask": "Prečistiť používateľské dáta",
- "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní."
+ "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.",
+ "LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
+ "Original": "Originál"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json
index 8c8ed3254a..a1b5b714af 100644
--- a/Emby.Server.Implementations/Localization/Core/sl-SI.json
+++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json
@@ -106,5 +106,7 @@
"TaskAudioNormalization": "Normalizacija zvoka",
"TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.",
"CleanupUserDataTask": "Čiščenje uporabniških podatkov",
- "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo."
+ "CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.",
+ "LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
+ "Original": "Original"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json
index b1f76aafbb..e13f7b09e1 100644
--- a/Emby.Server.Implementations/Localization/Core/sq.json
+++ b/Emby.Server.Implementations/Localization/Core/sq.json
@@ -106,5 +106,7 @@
"TaskAudioNormalization": "Normalizimi i audios",
"TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.",
"CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
- "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve"
+ "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve",
+ "LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
+ "Original": "Origjinal"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sw.json b/Emby.Server.Implementations/Localization/Core/sw.json
index 0967ef424b..c117a0eae2 100644
--- a/Emby.Server.Implementations/Localization/Core/sw.json
+++ b/Emby.Server.Implementations/Localization/Core/sw.json
@@ -1 +1,5 @@
-{}
+{
+ "Artists": "Wasanii",
+ "Books": "Vitabu",
+ "Collections": "Mikusanyiko"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index 1098880cf3..b2fcbc93a2 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -77,8 +77,8 @@
"TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。",
"TaskCleanLogs": "清理日誌資料夾",
"TaskRefreshLibrary": "掃描媒體櫃",
- "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節縮圖。",
- "TaskRefreshChapterImages": "擷取章節圖片",
+ "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。",
+ "TaskRefreshChapterImages": "擷取章節圖像",
"TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。",
"TaskCleanCache": "清理快取(Cache)資料夾",
"TasksChannelsCategory": "網路頻道",
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index 5dace3b0b7..4e0dff87b9 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImages": "遷移快轉縮圖位置",
"TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
"CleanupUserDataTask": "用戶資料清理工作",
- "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
+ "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。",
+ "Original": "原作",
+ "LyricDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的歌詞"
}
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 843e35afcc..e7dd984ec4 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -356,6 +356,27 @@ namespace Emby.Server.Implementations.Localization
{
ArgumentException.ThrowIfNullOrEmpty(rating);
+ // Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år").
+ // Try each one in order and use the first that resolves.
+ var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var ratingValue in ratingValues)
+ {
+ var score = GetSingleRatingScore(ratingValue, countryCode);
+ if (score is not null)
+ {
+ return score;
+ }
+ }
+
+ return null;
+ }
+
+ /// <summary>
+ /// Resolves a single rating value to a score.
+ /// </summary>
+ private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode)
+ {
// Handle unrated content
if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
{
@@ -364,7 +385,7 @@ namespace Emby.Server.Implementations.Localization
// Convert ints directly
// This may override some of the locale specific age ratings (but those always map to the same age)
- if (int.TryParse(rating, out var ratingAge))
+ if (TryParseRatingAsScore(rating, out var ratingAge))
{
return new(ratingAge, null);
}
@@ -466,6 +487,13 @@ namespace Emby.Server.Implementations.Localization
return true;
}
+ // If it's not a recognized rating string, fall back to using the number as the score
+ if (TryParseRatingAsScore(ratingPart, out var numericScore))
+ {
+ result = new ParentalRatingScore(numericScore, null);
+ return true;
+ }
+
_logger.LogWarning(
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
rating,
@@ -480,6 +508,18 @@ namespace Emby.Server.Implementations.Localization
return true;
}
+ /// <summary>
+ /// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+").
+ /// </summary>
+ /// <param name="ratingValue">Rating value to parse.</param>
+ /// <param name="score">Parsed score.</param>
+ /// <returns>Returns true if parsing was successful.</returns>
+ private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
+ {
+ var trimmed = ratingValue.TrimEnd('+');
+ return int.TryParse(trimmed, out score);
+ }
+
/// <inheritdoc />
public string GetLocalizedString(string phrase)
{
@@ -566,11 +606,15 @@ namespace Emby.Server.Implementations.Localization
private static string GetResourceFilename(string culture)
{
- var parts = culture.Split('-');
+ // Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator.
+ // Normalize the casing (lower-case language, upper-case region) while preserving the separator
+ // so the result matches the embedded resource file name, which is case-sensitive.
+ var separatorIndex = culture.IndexOfAny(['-', '_']);
- if (parts.Length == 2)
+ if (separatorIndex > 0)
{
- culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
+ var separator = culture[separatorIndex];
+ culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant();
}
else
{
diff --git a/Emby.Server.Implementations/Localization/Ratings/gr.json b/Emby.Server.Implementations/Localization/Ratings/gr.json
index 794bf0b313..73abab72d3 100644
--- a/Emby.Server.Implementations/Localization/Ratings/gr.json
+++ b/Emby.Server.Implementations/Localization/Ratings/gr.json
@@ -10,21 +10,42 @@
}
},
{
- "ratingStrings": ["K12"],
+ "ratingStrings": ["K12", "K-12", "12"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["K13", "K-13", "13"],
"ratingScore": {
"score": 13,
"subScore": null
}
},
{
- "ratingStrings": ["K15"],
+ "ratingStrings": ["K15", "K-15", "15"],
"ratingScore": {
"score": 15,
"subScore": null
}
},
{
- "ratingStrings": ["K18"],
+ "ratingStrings": ["K16", "K-16", "16"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["K17", "K-17", "17"],
+ "ratingScore": {
+ "score": 17,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["K18", "K-18", "18", "18+"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/se.json b/Emby.Server.Implementations/Localization/Ratings/se.json
index 70084995d1..818565e16b 100644
--- a/Emby.Server.Implementations/Localization/Ratings/se.json
+++ b/Emby.Server.Implementations/Localization/Ratings/se.json
@@ -10,7 +10,7 @@
}
},
{
- "ratingStrings": ["7"],
+ "ratingStrings": ["7", "7+", "7 År", "Från 7 år"],
"ratingScore": {
"score": 7,
"subScore": null
@@ -31,7 +31,7 @@
}
},
{
- "ratingStrings": ["11"],
+ "ratingStrings": ["11", "11+", "11 År", "Från 11 år"],
"ratingScore": {
"score": 11,
"subScore": null
@@ -45,11 +45,18 @@
}
},
{
- "ratingStrings": ["15"],
+ "ratingStrings": ["15", "15+", "15 År", "Från 15 år"],
"ratingScore": {
"score": 15,
"subScore": null
}
+ },
+ {
+ "ratingStrings": ["18", "18+", "Barnförbjuden", "Bfj"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
}
]
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
index b2dc89be28..e4939205c9 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask
{
FileName = _mediaEncoder.EncoderPath,
Arguments = args,
- RedirectStandardOutput = false,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true
},
})
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
index 92d7a3907a..8d133dc074 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
private readonly ILogger<OptimizeDatabaseTask> _logger;
private readonly ILocalizationManager _localization;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
+ private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
@@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public OptimizeDatabaseTask(
ILogger<OptimizeDatabaseTask> logger,
ILocalizationManager localization,
- IJellyfinDatabaseProvider jellyfinDatabaseProvider)
+ IJellyfinDatabaseProvider jellyfinDatabaseProvider,
+ ILibraryManager libraryManager)
{
_logger = logger;
_localization = localization;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+ _libraryManager = libraryManager;
}
/// <inheritdoc />
@@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
+ // Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in
+ // progress causes both operations to contend for the database and can stall the scan, so defer optimization
+ // until no scan is running. The task will run again on its next trigger.
+ if (_libraryManager.IsScanRunning)
+ {
+ _logger.LogInformation("Skipping database optimization because a library scan is currently running.");
+ return;
+ }
+
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
try
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
index 6e4e5c7808..dff9a473af 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
@@ -4,11 +4,18 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
@@ -20,6 +27,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
private readonly ILibraryManager _libraryManager;
private readonly ILocalizationManager _localization;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger<PeopleValidationTask> _logger;
+ private readonly IItemTypeLookup _itemTypeLookup;
/// <summary>
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
@@ -27,11 +37,23 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
- public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory)
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
+ /// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
+ public PeopleValidationTask(
+ ILibraryManager libraryManager,
+ ILocalizationManager localization,
+ IDbContextFactory<JellyfinDbContext> dbContextFactory,
+ IFileSystem fileSystem,
+ ILogger<PeopleValidationTask> logger,
+ IItemTypeLookup itemTypeLookup)
{
_libraryManager = libraryManager;
_localization = localization;
_dbContextFactory = dbContextFactory;
+ _fileSystem = fileSystem;
+ _logger = logger;
+ _itemTypeLookup = itemTypeLookup;
}
/// <inheritdoc />
@@ -71,13 +93,19 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
- IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
- await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false);
+ // People validation performs heavy database writes that contend with an active library scan.
+ // Defer it until the scan has finished; the task will run again on its next trigger.
+ if (_libraryManager.IsScanRunning)
+ {
+ _logger.LogInformation("Skipping people validation because a library scan is currently running.");
+ return;
+ }
- subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
+ // Phase 1: Deduplicate and remove orphaned people (0-33%)
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
+ IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 3));
var dupQuery = context.Peoples
.GroupBy(e => new { e.Name, e.PersonType })
.Where(e => e.Count() > 1)
@@ -123,7 +151,113 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
ArrayPool<Guid[]>.Shared.Return(buffer);
}
+ var peopleToDelete = await context.Peoples
+ .Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id)))
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ _logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete);
+
subProgress.Report(100);
}
+
+ // Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
+ // cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
+ IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
+ await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
+
+ // Phase 3: Refresh images for people missing them (66-100%)
+ IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
+ await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false);
+
+ progress.Report(100);
+ }
+
+ private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
+
+ var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ const int PartitionSize = 100;
+
+ var numPeople = await context.BaseItems
+ .AsNoTracking()
+ .Where(b => b.Type == personTypeName)
+ .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
+ .Where(b =>
+ !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
+ string.IsNullOrEmpty(b.Overview))
+ .CountAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople);
+
+ if (numPeople == 0)
+ {
+ progress.Report(100);
+ return;
+ }
+
+ var numComplete = 0;
+ var numRefreshed = 0;
+
+ await foreach (var entry in context.BaseItems
+ .AsNoTracking()
+ .Where(b => b.Type == personTypeName)
+ .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
+ .Where(b =>
+ !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
+ string.IsNullOrEmpty(b.Overview))
+ .OrderBy(b => b.Id)
+ .WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition))
+ .PartitionEagerAsync(PartitionSize, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false))
+ {
+ numRefreshed++;
+ }
+
+ numComplete++;
+ progress.Report(100.0 * numComplete / numPeople);
+ }
+
+ _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
+ }
+ }
+
+ private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (_libraryManager.GetItemById(personId) is not Person item)
+ {
+ return false;
+ }
+
+ var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary);
+ var hasOverview = !string.IsNullOrEmpty(item.Overview);
+
+ var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+ {
+ ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
+ MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
+ };
+
+ await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error refreshing images for person {PersonId}", personId);
+ return false;
+ }
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 18811ef3a9..828bdd6859 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
}
}
+ else
+ {
+ liveStreamNeedsToBeClosed = true;
+ }
if (liveStreamNeedsToBeClosed)
{
@@ -726,6 +730,31 @@ namespace Emby.Server.Implementations.Session
}
/// <summary>
+ /// Resolves the item whose user data (playback position, played status) should be updated
+ /// for a playback report. When an alternate version is played the client reports the displayed
+ /// item as <c>ItemId</c> and the played version as <c>MediaSourceId</c>.
+ /// </summary>
+ /// <param name="libraryItem">The now playing (displayed) item.</param>
+ /// <param name="mediaSourceId">The reported media source id.</param>
+ /// <returns>The item to track progress against.</returns>
+ private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId)
+ {
+ if (libraryItem is Video libraryVideo
+ && !string.IsNullOrEmpty(mediaSourceId)
+ && Guid.TryParse(mediaSourceId, out var mediaSourceItemId)
+ && !mediaSourceItemId.Equals(libraryVideo.Id))
+ {
+ var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId);
+ if (versionItem is not null)
+ {
+ return versionItem;
+ }
+ }
+
+ return libraryItem;
+ }
+
+ /// <summary>
/// Used to report that playback has started for an item.
/// </summary>
/// <param name="info">The info.</param>
@@ -756,9 +785,10 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- OnPlaybackStart(user, libraryItem);
+ OnPlaybackStart(user, progressItem);
}
}
@@ -890,9 +920,10 @@ namespace Emby.Server.Implementations.Session
// only update saved user data on actual check-ins, not automated ones
if (libraryItem is not null && !isAutomated)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- OnPlaybackProgress(user, libraryItem, info);
+ OnPlaybackProgress(user, progressItem, info);
}
}
@@ -952,6 +983,20 @@ namespace Emby.Server.Implementations.Session
if (changed)
{
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None);
+
+ // A completed version marks every alternate version played and clears their resume points, so the
+ // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
+ // only persist while nothing has been completed yet.)
+ if (data.Played == true && item is Video playedVideo)
+ {
+ playedVideo.PropagatePlayedState(user, true);
+ }
+ }
+
+ if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue)
+ || (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue))
+ {
+ _userDataManager.ResetPlaybackStreamSelections(user, item);
}
}
@@ -1083,9 +1128,10 @@ namespace Emby.Server.Implementations.Session
if (libraryItem is not null)
{
+ var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
foreach (var user in users)
{
- playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed);
+ playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed);
}
}
@@ -1138,6 +1184,14 @@ namespace Emby.Server.Implementations.Session
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None);
+ // A completed version marks every alternate version played and clears their resume points, so the
+ // whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
+ // only persist while nothing has been completed yet.)
+ if (data.Played == true && item is Video playedVideo)
+ {
+ playedVideo.PropagatePlayedState(user, true);
+ }
+
return playedToCompletion;
}
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index 6a26e92e14..2582ed9df0 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -246,8 +246,21 @@ namespace Emby.Server.Implementations.Session
_logger.LogInformation("Lost {0} WebSockets.", lost.Count);
foreach (var webSocket in lost)
{
- // TODO: handle session relative to the lost webSocket
RemoveWebSocket(webSocket);
+
+ // The connection stopped answering keep-alives, so a close frame will
+ // never arrive and the pending receive loop would hang forever, keeping
+ // the session (and e.g. its SyncPlay group membership) alive. Disposing
+ // the connection aborts the receive loop, which raises Closed and lets
+ // the session end normally.
+ try
+ {
+ webSocket.Dispose();
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint);
+ }
}
}
}
diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs
index c2e834ad58..38a0018a70 100644
--- a/Emby.Server.Implementations/SyncPlay/Group.cs
+++ b/Emby.Server.Implementations/SyncPlay/Group.cs
@@ -206,7 +206,8 @@ namespace Emby.Server.Implementations.SyncPlay
foreach (var itemId in queue)
{
var item = _libraryManager.GetItemById(itemId);
- if (!item.IsVisibleStandalone(user))
+
+ if (item is null || !item.IsVisibleStandalone(user))
{
return false;
}
diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs
index 535dc01a31..459ad1a17e 100644
--- a/Emby.Server.Implementations/TV/TVSeriesManager.cs
+++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using Jellyfin.Data;
using Jellyfin.Data.Enums;
@@ -136,11 +137,15 @@ namespace Emby.Server.Implementations.TV
if (nextEpisode is not null)
{
+ // The last played date and the version that was actually played live on the version item's user data
+ // The played state propagated to the sibling versions carries no date
+ var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user);
+ nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion);
+
DateTime lastWatchedDate = DateTime.MinValue;
if (result.LastWatched is not null)
{
- var userData = _userDataManager.GetUserData(user, result.LastWatched);
- lastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
+ lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
}
nextUpList.Add((lastWatchedDate, nextEpisode));
@@ -152,11 +157,13 @@ namespace Emby.Server.Implementations.TV
if (nextPlayedEpisode is not null)
{
+ var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user);
+ nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion);
+
DateTime rewatchLastWatchedDate = DateTime.MinValue;
if (result.LastWatchedForRewatching is not null)
{
- var userData = _userDataManager.GetUserData(user, result.LastWatchedForRewatching);
- rewatchLastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
+ rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
}
nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
@@ -219,10 +226,13 @@ namespace Emby.Server.Implementations.TV
if (nextEpisode is not null && !includeResumable)
{
- var userData = _userDataManager.GetUserData(user, nextEpisode);
- if (userData?.PlaybackPositionTicks > 0)
+ // The resume progress may live on an alternate version
+ foreach (var version in nextEpisode.GetAllVersions())
{
- return null;
+ if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0)
+ {
+ return null;
+ }
}
}
@@ -237,6 +247,74 @@ namespace Emby.Server.Implementations.TV
return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
}
+ /// <summary>
+ /// Gets the version of the last watched episode that was actually played, together with its last played date.
+ /// The version that was played carries the most recent LastPlayedDate.
+ /// dates.
+ /// </summary>
+ /// <param name="lastWatched">The last watched episode (any version).</param>
+ /// <param name="user">The user.</param>
+ /// <returns>The played version and its last played date.</returns>
+ private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user)
+ {
+ if (lastWatched is not Video lastWatchedVideo)
+ {
+ return (null, null);
+ }
+
+ var versions = lastWatchedVideo.GetAllVersions();
+ var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
+
+ var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
+ versions,
+ version => userDataByVersion.GetValueOrDefault(version.Id),
+ data => data.LastPlayedDate.HasValue);
+
+ return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
+ }
+
+ /// <summary>
+ /// When the last watched episode was played as an alternate version, prefer the next episode's version with the matching name,
+ /// so Next Up continues in the version the user has been watching instead of falling back to the primary.
+ /// </summary>
+ /// <param name="nextEpisode">The determined next episode (a primary).</param>
+ /// <param name="lastWatched">The last watched episode.</param>
+ /// <param name="playedVersion">The version of the last watched episode that was played.</param>
+ /// <returns>The matching version of the next episode, or the episode itself.</returns>
+ private Episode GetPreferredVersion(Episode nextEpisode, BaseItem? lastWatched, Video? playedVersion)
+ {
+ // No version preference, or the primary was played
+ if (lastWatched is not Video lastWatchedVideo
+ || playedVersion is null
+ || !playedVersion.PrimaryVersionId.HasValue)
+ {
+ return nextEpisode;
+ }
+
+ // Match by version name
+ var playedVersionId = playedVersion.Id.ToString("N", CultureInfo.InvariantCulture);
+ var playedVersionName = lastWatchedVideo.GetMediaSources(false)
+ .FirstOrDefault(source => string.Equals(source.Id, playedVersionId, StringComparison.OrdinalIgnoreCase))?.Name;
+
+ if (string.IsNullOrEmpty(playedVersionName))
+ {
+ return nextEpisode;
+ }
+
+ var matchingSource = nextEpisode.GetMediaSources(false)
+ .FirstOrDefault(source => string.Equals(source.Name, playedVersionName, StringComparison.OrdinalIgnoreCase));
+
+ if (matchingSource is not null
+ && Guid.TryParse(matchingSource.Id, out var matchingId)
+ && !matchingId.Equals(nextEpisode.Id)
+ && _libraryManager.GetItemById<Episode>(matchingId) is { } matchingVersion)
+ {
+ return matchingVersion;
+ }
+
+ return nextEpisode;
+ }
+
private static string GetUniqueSeriesKey(Series series)
{
return series.GetPresentationUniqueKey();
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index ef53e3b326..6a60f7f5f6 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -1,4 +1,5 @@
using System;
+using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
@@ -32,6 +33,8 @@ namespace Emby.Server.Implementations.Updates
/// </summary>
public class InstallationManager : IInstallationManager
{
+ private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
+
/// <summary>
/// The logger.
/// </summary>
@@ -521,9 +524,27 @@ namespace Emby.Server.Implementations.Updates
return;
}
+ if (!IsValidPackageDirectoryName(package.Name))
+ {
+ _logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name);
+ throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name.");
+ }
+
// Always override the passed-in target (which is a file) and figure it out again
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
+ var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath));
+ var resolvedTarget = Path.GetFullPath(targetDir);
+ if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogError(
+ "Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.",
+ package.Name,
+ resolvedTarget,
+ pluginsRoot);
+ throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
+ }
+
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
@@ -572,6 +593,26 @@ namespace Emby.Server.Implementations.Updates
_pluginManager.ImportPluginFrom(targetDir);
}
+ private static bool IsValidPackageDirectoryName(string? name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return false;
+ }
+
+ if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (name.IndexOfAny(InvalidPackageNameChars) >= 0)
+ {
+ return false;
+ }
+
+ return true;
+ }
+
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
{
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))
diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs
index d6cc0e71a4..8e17dd7d53 100644
--- a/Jellyfin.Api/Controllers/ActivityLogController.cs
+++ b/Jellyfin.Api/Controllers/ActivityLogController.cs
@@ -1,6 +1,6 @@
using System;
-using System.Collections.Generic;
using System.Threading.Tasks;
+using Jellyfin.Api.Helpers;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations.Enums;
@@ -84,36 +84,9 @@ public class ActivityLogController : BaseJellyfinApiController
ItemId = itemId,
Username = username,
Severity = severity,
- OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []),
+ OrderBy = RequestHelpers.GetOrderBy(sortBy ?? [], sortOrder ?? []),
};
return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false);
}
-
- private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy(
- IReadOnlyList<ActivityLogSortBy> sortBy,
- IReadOnlyList<SortOrder> requestedSortOrder)
- {
- if (sortBy.Count == 0)
- {
- return [];
- }
-
- var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count];
- var i = 0;
- for (; i < requestedSortOrder.Count; i++)
- {
- result[i] = (sortBy[i], requestedSortOrder[i]);
- }
-
- // Add remaining elements with the first specified SortOrder
- // or the default one if no SortOrders are specified
- var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
- for (; i < sortBy.Count; i++)
- {
- result[i] = (sortBy[i], order);
- }
-
- return result;
- }
}
diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs
index b5365cd632..c61ee45830 100644
--- a/Jellyfin.Api/Controllers/HlsSegmentController.cs
+++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs
@@ -60,11 +60,8 @@ public class HlsSegmentController : BaseJellyfinApiController
public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId)
{
// TODO: Deprecate with new iOS app
- var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
- var transcodePath = _serverConfigurationManager.GetTranscodePath();
- file = Path.GetFullPath(Path.Combine(transcodePath, file));
- var fileDir = Path.GetDirectoryName(file);
- if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture))
+ var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
+ if (file is null)
{
return BadRequest("Invalid segment.");
}
@@ -86,12 +83,9 @@ public class HlsSegmentController : BaseJellyfinApiController
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId)
{
- var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan()));
- var transcodePath = _serverConfigurationManager.GetTranscodePath();
- file = Path.GetFullPath(Path.Combine(transcodePath, file));
- var fileDir = Path.GetDirectoryName(file);
- if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture)
- || Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
+ var file = ValidateTranscodePath(string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())));
+ if (file is null
+ || !Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Invalid segment.");
}
@@ -140,18 +134,13 @@ public class HlsSegmentController : BaseJellyfinApiController
[FromRoute, Required] string segmentId,
[FromRoute, Required] string segmentContainer)
{
- var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan()));
- var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
-
- file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file));
- var fileDir = Path.GetDirectoryName(file);
- if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodeFolderPath, StringComparison.InvariantCulture))
+ var file = ValidateTranscodePath(string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())));
+ if (file is null)
{
return BadRequest("Invalid segment.");
}
- var normalizedPlaylistId = playlistId;
-
+ var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath();
var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
// Add . to start of segment container for future use.
segmentContainer = segmentContainer.Insert(0, ".");
@@ -161,7 +150,7 @@ public class HlsSegmentController : BaseJellyfinApiController
var pathExtension = Path.GetExtension(path);
if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
|| string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
- && path.Contains(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase))
+ && path.Contains(playlistId, StringComparison.OrdinalIgnoreCase))
{
playlistPath = path;
break;
@@ -173,6 +162,19 @@ public class HlsSegmentController : BaseJellyfinApiController
: GetFileResult(file, playlistPath);
}
+ private string? ValidateTranscodePath(string filename)
+ {
+ var transcodePath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_serverConfigurationManager.GetTranscodePath()));
+ var file = Path.GetFullPath(filename, transcodePath);
+ // Require a separator after the transcode path so a sibling like "<transcodePath>-evil" can't pass.
+ if (!file.StartsWith(transcodePath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return file;
+ }
+
private ActionResult GetFileResult(string path, string playlistPath)
{
var transcodingJob = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType.Hls);
diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs
index ae792142b4..d492a2f5ba 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -125,7 +125,13 @@ public class ImageController : BaseJellyfinApiController
{
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
- var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+ var userConfigurationDirectoryPath = _serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath;
+ var userDataPath = Path.Combine(userConfigurationDirectoryPath, user.Username);
+ if (!PathHelper.IsContainedIn(userConfigurationDirectoryPath, userDataPath))
+ {
+ return BadRequest("Invalid user.");
+ }
+
if (user.ProfileImage is not null)
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
@@ -2049,7 +2055,7 @@ public class ImageController : BaseJellyfinApiController
}
// Check If-Modified-Since header for time-based validation
- if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader))
+ if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], CultureInfo.InvariantCulture, out var ifModifiedSinceHeader))
{
// Return 304 if the image has not been modified since the client's cached version
if (dateImageModified <= ifModifiedSinceHeader)
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index d560ee8238..e26264d292 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -279,6 +279,11 @@ public class ItemUpdateController : BaseJellyfinApiController
item.DateCreated = NormalizeDateTime(request.DateCreated.Value);
}
+ if (request.SeriesName is not null && item is IHasSeries hasSeries)
+ {
+ hasSeries.SeriesName = request.SeriesName;
+ }
+
item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
item.ProductionYear = request.ProductionYear;
@@ -297,6 +302,8 @@ public class ItemUpdateController : BaseJellyfinApiController
{
foreach (var season in rseries.Children.OfType<Season>())
{
+ season.SeriesName = rseries.Name;
+
if (!season.LockedFields.Contains(MetadataField.OfficialRating))
{
season.OfficialRating = request.OfficialRating;
@@ -314,6 +321,8 @@ public class ItemUpdateController : BaseJellyfinApiController
foreach (var ep in season.Children.OfType<Episode>())
{
+ ep.SeriesName = rseries.Name;
+
if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
{
ep.OfficialRating = request.OfficialRating;
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 5f23f2fcee..385795e9f7 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -287,6 +287,8 @@ public class ItemsController : BaseJellyfinApiController
QueryResult<BaseItem> result;
Guid[] linkedChildAncestorIds = [];
+
+ includeItemTypes ??= [];
if (includeItemTypes.Length == 1
&& (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist)
&& item is not BoxSet
@@ -321,24 +323,20 @@ public class ItemsController : BaseJellyfinApiController
recursive = true;
includeItemTypes = [BaseItemKind.Playlist];
}
- else if (folder is ICollectionFolder)
+ else if (folder is ICollectionFolder && includeItemTypes.Length == 0)
{
- if (includeItemTypes.Length == 0)
+ includeItemTypes = collectionType switch
{
- includeItemTypes = collectionType switch
- {
- CollectionType.boxsets => [BaseItemKind.BoxSet],
- null => [BaseItemKind.Movie, BaseItemKind.Series],
- _ => []
- };
- }
+ CollectionType.boxsets => [BaseItemKind.BoxSet],
+ _ => []
+ };
+ }
- // When the client doesn't specify recursive/includeItemTypes, force the query
- // through the database path where all filters (IsHD, genres, etc.) are applied.
- if (includeItemTypes.Length > 0)
- {
- recursive ??= true;
- }
+ // includeItemTypes on a library lists its contents recursively rather than just its
+ // immediate children, so default to a recursive query when the client didn't choose.
+ if (folder is ICollectionFolder && includeItemTypes.Length > 0)
+ {
+ recursive ??= true;
}
if (item is not UserRootFolder
@@ -351,246 +349,248 @@ public class ItemsController : BaseJellyfinApiController
return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
}
- if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
- {
- // Use search providers when searchTerm is provided. Providers return only IDs and scores;
- // items are loaded server-side via folder.GetItems below, which applies user-access filtering.
- Dictionary<Guid, float>? searchResultScores = null;
- Guid[] itemIds = ids;
-
- if (!string.IsNullOrWhiteSpace(searchTerm))
- {
- var searchProviderQuery = new SearchProviderQuery
- {
- SearchTerm = searchTerm,
- UserId = userId,
- IncludeItemTypes = includeItemTypes,
- ExcludeItemTypes = excludeItemTypes,
- MediaTypes = mediaTypes,
- Limit = limit.HasValue ? limit.Value * 3 : null,
- ParentId = parentId
- };
-
- var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false);
- if (searchResults.Count > 0)
- {
- searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score);
- itemIds = ids.Length > 0
- ? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
- : searchResultScores.Keys.ToArray();
- }
- }
+ // Build the query up front so the dispatch below can decide the path from it.
+ // Use search providers when searchTerm is provided. Providers return only IDs and scores;
+ // items are loaded server-side via folder.GetItems below, which applies user-access filtering.
+ Dictionary<Guid, float>? searchResultScores = null;
+ Guid[] itemIds = ids;
- var query = new InternalItemsQuery(user)
+ if (!string.IsNullOrWhiteSpace(searchTerm))
+ {
+ var searchProviderQuery = new SearchProviderQuery
{
- IsPlayed = isPlayed,
- MediaTypes = mediaTypes,
+ SearchTerm = searchTerm,
+ UserId = userId,
IncludeItemTypes = includeItemTypes,
ExcludeItemTypes = excludeItemTypes,
- Recursive = recursive ?? false,
- OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
- IsFavorite = isFavorite,
- Limit = searchResultScores is null ? limit : null,
- StartIndex = searchResultScores is null ? startIndex : null,
- IsMissing = isMissing,
- IsUnaired = isUnaired,
- CollapseBoxSetItems = collapseBoxSetItems,
- NameLessThan = nameLessThan,
- NameStartsWith = nameStartsWith,
- NameStartsWithOrGreater = nameStartsWithOrGreater,
- HasImdbId = hasImdbId,
- IsPlaceHolder = isPlaceHolder,
- IsLocked = isLocked,
- MinWidth = minWidth,
- MinHeight = minHeight,
- MaxWidth = maxWidth,
- MaxHeight = maxHeight,
- Is3D = is3D,
- HasTvdbId = hasTvdbId,
- HasTmdbId = hasTmdbId,
- IsMovie = isMovie,
- IsSeries = isSeries,
- IsNews = isNews,
- IsKids = isKids,
- IsSports = isSports,
- HasOverview = hasOverview,
- HasOfficialRating = hasOfficialRating,
- HasParentalRating = hasParentalRating,
- HasSpecialFeature = hasSpecialFeature,
- HasSubtitles = hasSubtitles,
- HasThemeSong = hasThemeSong,
- HasThemeVideo = hasThemeVideo,
- HasTrailer = hasTrailer,
- IsHD = isHd,
- Is4K = is4K,
- Tags = tags,
- OfficialRatings = officialRatings,
- Genres = genres,
- ArtistIds = artistIds,
- AlbumArtistIds = albumArtistIds,
- ContributingArtistIds = contributingArtistIds,
- GenreIds = genreIds,
- StudioIds = studioIds,
- Person = person,
- PersonIds = personIds,
- PersonTypes = personTypes,
- Years = years,
- ImageTypes = imageTypes,
- VideoTypes = videoTypes,
- AdjacentTo = adjacentTo,
- ItemIds = itemIds,
- MinCommunityRating = minCommunityRating,
- MinCriticRating = minCriticRating,
- ParentId = parentId ?? Guid.Empty,
- IndexNumber = indexNumber,
- ParentIndexNumber = parentIndexNumber,
- EnableTotalRecordCount = enableTotalRecordCount,
- ExcludeItemIds = excludeItemIds,
- DtoOptions = dtoOptions,
- SearchTerm = searchResultScores is null ? searchTerm : null,
- MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
- MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
- MinPremiereDate = minPremiereDate?.ToUniversalTime(),
- MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
- AudioLanguages = audioLanguages,
- SubtitleLanguages = subtitleLanguages,
- LinkedChildAncestorIds = linkedChildAncestorIds,
+ MediaTypes = mediaTypes,
+ Limit = limit.HasValue ? limit.Value * 3 : null,
+ ParentId = parentId
};
- if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
+ var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false);
+ if (searchResults.Count > 0)
{
- query.CollapseBoxSetItems = false;
+ searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score);
+ itemIds = ids.Length > 0
+ ? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
+ : searchResultScores.Keys.ToArray();
}
+ }
- if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
+ var query = new InternalItemsQuery(user)
+ {
+ IsPlayed = isPlayed,
+ MediaTypes = mediaTypes,
+ IncludeItemTypes = includeItemTypes,
+ ExcludeItemTypes = excludeItemTypes,
+ Recursive = recursive ?? false,
+ OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
+ IsFavorite = isFavorite,
+ Limit = searchResultScores is null ? limit : null,
+ StartIndex = searchResultScores is null ? startIndex : null,
+ IsMissing = isMissing,
+ IsUnaired = isUnaired,
+ CollapseBoxSetItems = collapseBoxSetItems,
+ NameLessThan = nameLessThan,
+ NameStartsWith = nameStartsWith,
+ NameStartsWithOrGreater = nameStartsWithOrGreater,
+ HasImdbId = hasImdbId,
+ IsPlaceHolder = isPlaceHolder,
+ IsLocked = isLocked,
+ MinWidth = minWidth,
+ MinHeight = minHeight,
+ MaxWidth = maxWidth,
+ MaxHeight = maxHeight,
+ Is3D = is3D,
+ HasTvdbId = hasTvdbId,
+ HasTmdbId = hasTmdbId,
+ IsMovie = isMovie,
+ IsSeries = isSeries,
+ IsNews = isNews,
+ IsKids = isKids,
+ IsSports = isSports,
+ HasOverview = hasOverview,
+ HasOfficialRating = hasOfficialRating,
+ HasParentalRating = hasParentalRating,
+ HasSpecialFeature = hasSpecialFeature,
+ HasSubtitles = hasSubtitles,
+ HasThemeSong = hasThemeSong,
+ HasThemeVideo = hasThemeVideo,
+ HasTrailer = hasTrailer,
+ IsHD = isHd,
+ Is4K = is4K,
+ Tags = tags,
+ OfficialRatings = officialRatings,
+ Genres = genres,
+ ArtistIds = artistIds,
+ AlbumArtistIds = albumArtistIds,
+ ContributingArtistIds = contributingArtistIds,
+ GenreIds = genreIds,
+ StudioIds = studioIds,
+ Person = person,
+ PersonIds = personIds,
+ PersonTypes = personTypes,
+ Years = years,
+ ImageTypes = imageTypes,
+ VideoTypes = videoTypes,
+ AdjacentTo = adjacentTo,
+ ItemIds = itemIds,
+ MinCommunityRating = minCommunityRating,
+ MinCriticRating = minCriticRating,
+ ParentId = parentId ?? Guid.Empty,
+ IndexNumber = indexNumber,
+ ParentIndexNumber = parentIndexNumber,
+ EnableTotalRecordCount = enableTotalRecordCount,
+ ExcludeItemIds = excludeItemIds,
+ DtoOptions = dtoOptions,
+ SearchTerm = searchResultScores is null ? searchTerm : null,
+ MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
+ MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
+ MinPremiereDate = minPremiereDate?.ToUniversalTime(),
+ MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
+ AudioLanguages = audioLanguages,
+ SubtitleLanguages = subtitleLanguages,
+ LinkedChildAncestorIds = linkedChildAncestorIds,
+ };
+
+ if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
+ {
+ query.CollapseBoxSetItems = false;
+ }
+
+ if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
+ {
+ if (query.HasSubtitles.Value)
{
- if (query.HasSubtitles.Value)
- {
- // if we check for specific subtitles we don't need a separate check for subtitle existence
- query.HasSubtitles = null;
- }
- else
- {
- // if we search for items without subtitles, we don't need to check for subtitles of a specific language
- query.SubtitleLanguages = [];
- }
+ // if we check for specific subtitles we don't need a separate check for subtitle existence
+ query.HasSubtitles = null;
}
-
- // for filter values that rely on media streams, we need to include alternative and linked versions
- if (query.HasSubtitles.HasValue
- || query.SubtitleLanguages.Count > 0
- || query.AudioLanguages.Count > 0
- || query.Is3D.HasValue
- || query.IsHD.HasValue
- || query.Is4K.HasValue
- || query.VideoTypes.Length > 0
- )
+ else
{
- query.IncludeOwnedItems = true;
+ // if we search for items without subtitles, we don't need to check for subtitles of a specific language
+ query.SubtitleLanguages = [];
}
+ }
- query.ApplyFilters(filters);
+ // for filter values that rely on media streams, we need to include alternative and linked versions
+ if (query.HasSubtitles.HasValue
+ || query.SubtitleLanguages.Count > 0
+ || query.AudioLanguages.Count > 0
+ || query.Is3D.HasValue
+ || query.IsHD.HasValue
+ || query.Is4K.HasValue
+ || query.VideoTypes.Length > 0
+ )
+ {
+ query.IncludeOwnedItems = true;
+ }
- // Filter by Series Status
- if (seriesStatus.Length != 0)
- {
- query.SeriesStatuses = seriesStatus;
- }
+ query.ApplyFilters(filters);
- // Exclude Blocked Unrated Items
- var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems);
- if (blockedUnratedItems is not null)
- {
- query.BlockUnratedItems = blockedUnratedItems;
- }
+ // Filter by Series Status
+ if (seriesStatus.Length != 0)
+ {
+ query.SeriesStatuses = seriesStatus;
+ }
- // ExcludeLocationTypes
- if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
- {
- query.IsVirtualItem = false;
- }
+ // Exclude Blocked Unrated Items
+ var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems);
+ if (blockedUnratedItems is not null)
+ {
+ query.BlockUnratedItems = blockedUnratedItems;
+ }
- if (locationTypes.Length > 0 && locationTypes.Length < 4)
- {
- query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
- }
+ // ExcludeLocationTypes
+ if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
+ {
+ query.IsVirtualItem = false;
+ }
- // Min official rating
- if (!string.IsNullOrWhiteSpace(minOfficialRating))
- {
- query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
- }
+ if (locationTypes.Length > 0 && locationTypes.Length < 4)
+ {
+ query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
+ }
- // Max official rating
- if (!string.IsNullOrWhiteSpace(maxOfficialRating))
- {
- query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
- }
+ // Min official rating
+ if (!string.IsNullOrWhiteSpace(minOfficialRating))
+ {
+ query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
+ }
+
+ // Max official rating
+ if (!string.IsNullOrWhiteSpace(maxOfficialRating))
+ {
+ query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
+ }
- // Artists
- if (artists.Length != 0)
+ // Artists
+ if (artists.Length != 0)
+ {
+ query.ArtistIds = artists.Select(i =>
{
- query.ArtistIds = artists.Select(i =>
+ try
{
- try
- {
- return _libraryManager.GetArtist(i, new DtoOptions(false));
- }
- catch
- {
- return null;
- }
- }).Where(i => i is not null).Select(i => i!.Id).ToArray();
- }
+ return _libraryManager.GetArtist(i, new DtoOptions(false));
+ }
+ catch
+ {
+ return null;
+ }
+ }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+ }
- // ExcludeArtistIds
- if (excludeArtistIds.Length != 0)
- {
- query.ExcludeArtistIds = excludeArtistIds;
- }
+ // ExcludeArtistIds
+ if (excludeArtistIds.Length != 0)
+ {
+ query.ExcludeArtistIds = excludeArtistIds;
+ }
- if (albumIds.Length != 0)
- {
- query.AlbumIds = albumIds;
- }
+ if (albumIds.Length != 0)
+ {
+ query.AlbumIds = albumIds;
+ }
- // Albums
- if (albums.Length != 0)
+ // Albums
+ if (albums.Length != 0)
+ {
+ query.AlbumIds = albums.SelectMany(i =>
{
- query.AlbumIds = albums.SelectMany(i =>
- {
- return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
- }).ToArray();
- }
+ return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
+ }).ToArray();
+ }
- // Studios
- if (studios.Length != 0)
+ // Studios
+ if (studios.Length != 0)
+ {
+ query.StudioIds = studios.Select(i =>
{
- query.StudioIds = studios.Select(i =>
+ try
{
- try
- {
- return _libraryManager.GetStudio(i);
- }
- catch
- {
- return null;
- }
- }).Where(i => i is not null).Select(i => i!.Id).ToArray();
- }
-
- // Apply default sorting if none requested
- if (query.OrderBy.Count == 0)
- {
- // Albums by artist
- if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
+ return _libraryManager.GetStudio(i);
+ }
+ catch
{
- query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
+ return null;
}
+ }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+ }
+
+ // Apply default sorting if none requested
+ if (query.OrderBy.Count == 0)
+ {
+ // Albums by artist
+ if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
+ {
+ query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
}
+ }
- query.Parent = null;
+ query.Parent = null;
+ // At the user root an unfiltered, non-recursive request is a plain listing of the user's libraries
+ if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters)
+ {
// folder.GetItems applies user-access filtering via the InternalItemsQuery's User.
result = folder.GetItems(query);
if (searchResultScores is not null && searchResultScores.Count > 0)
@@ -963,9 +963,15 @@ public class ItemsController : BaseJellyfinApiController
var excludeItemIds = Array.Empty<Guid>();
if (excludeActiveSessions)
{
+ // NowPlayingItem.Id is the displayed/primary id, but resume queries surface the actually-played
+ // alternate version's own id. Expand each active session to every version id so an in-progress
+ // alternate is excluded too, instead of leaking back into the resume list.
excludeItemIds = _sessionManager.Sessions
.Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null)
- .Select(s => s.NowPlayingItem.Id)
+ .SelectMany(s => _libraryManager.GetItemById(s.NowPlayingItem.Id) is Video video
+ ? video.GetAllVersions().Select(v => v.Id)
+ : [s.NowPlayingItem.Id])
+ .Distinct()
.ToArray();
}
diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs
index 113298c251..1c570daf21 100644
--- a/Jellyfin.Api/Controllers/LiveTvController.cs
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -1002,9 +1002,7 @@ public class LiveTvController : BaseJellyfinApiController
{
if (!string.IsNullOrEmpty(pw))
{
- // TODO: remove ToLower when Convert.ToHexString supports lowercase
- // Schedules Direct requires the hex to be lowercase
- listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
+ listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw)));
}
return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
index 0105ecf7a7..79b8b60cb7 100644
--- a/Jellyfin.Api/Controllers/PluginsController.cs
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -6,6 +6,7 @@ using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
+using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Plugins;
@@ -226,10 +227,13 @@ public class PluginsController : BaseJellyfinApiController
return NotFound();
}
- if (!string.IsNullOrEmpty(plugin.Manifest.ImagePath))
+ string? imagePath = plugin.Manifest.ImagePath;
+ if (!string.IsNullOrWhiteSpace(imagePath))
{
- var imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath);
- if (!System.IO.File.Exists(imagePath))
+ var pluginPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(plugin.Path));
+ imagePath = Path.GetFullPath(imagePath, pluginPath);
+ // Require a separator after the plugin path so a sibling like "<pluginPath>-evil" can't pass.
+ if (imagePath.StartsWith(pluginPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) is false || System.IO.File.Exists(imagePath) is false)
{
return NotFound();
}
diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs
index 121db66858..6bddd85337 100644
--- a/Jellyfin.Api/Controllers/TrailersController.cs
+++ b/Jellyfin.Api/Controllers/TrailersController.cs
@@ -122,6 +122,7 @@ public class TrailersController : BaseJellyfinApiController
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Use GetItems with includeItemTypes=Trailer instead.")]
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers(
[FromQuery] Guid? userId,
[FromQuery] string? maxOfficialRating,
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index 340a54e13b..6b0f10e02a 100644
--- a/Jellyfin.Api/Controllers/TvShowsController.cs
+++ b/Jellyfin.Api/Controllers/TvShowsController.cs
@@ -277,8 +277,15 @@ public class TvShowsController : BaseJellyfinApiController
if (startItemId.HasValue)
{
+ // The start item may be an alternate version, which is not part of the episode listing; start from its primary episode instead.
+ var startId = startItemId.Value;
+ if (_libraryManager.GetItemById<Video>(startId)?.PrimaryVersionId is { } primaryVersionId)
+ {
+ startId = primaryVersionId;
+ }
+
episodes = episodes
- .SkipWhile(i => !startItemId.Value.Equals(i.Id))
+ .SkipWhile(i => !startId.Equals(i.Id))
.ToList();
}
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index 25f781e496..a718035528 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -551,8 +551,6 @@ public class UserLibraryController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
- dtoOptions.PreferEpisodeParentPoster = true;
-
var list = _userViewManager.GetLatestItems(
new LatestItemsQuery
{
diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs
index 9c24be82ea..36a8e197a2 100644
--- a/Jellyfin.Api/Extensions/DtoExtensions.cs
+++ b/Jellyfin.Api/Extensions/DtoExtensions.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Model.Entities;
@@ -10,6 +11,37 @@ namespace Jellyfin.Api.Extensions;
public static class DtoExtensions
{
/// <summary>
+ /// Gets the BaseItemKind values associated with the specified CollectionType.
+ /// </summary>
+ /// <param name="collectionType">The collection type to map to BaseItemKind values.</param>
+ /// <returns>An array of BaseItemKind values that correspond to the collection type.</returns>
+ public static BaseItemKind[] GetBaseItemKindsForCollectionType(CollectionType? collectionType)
+ {
+ switch (collectionType)
+ {
+ case CollectionType.movies:
+ return [BaseItemKind.Movie];
+ case CollectionType.tvshows:
+ return [BaseItemKind.Series];
+ case CollectionType.music:
+ return [BaseItemKind.MusicAlbum];
+ case CollectionType.musicvideos:
+ return [BaseItemKind.MusicVideo];
+ case CollectionType.books:
+ return [BaseItemKind.Book, BaseItemKind.AudioBook];
+ case CollectionType.boxsets:
+ return [BaseItemKind.BoxSet];
+ case CollectionType.homevideos:
+ case CollectionType.photos:
+ return [BaseItemKind.Video, BaseItemKind.Photo];
+ case CollectionType.folders:
+ return [];
+ default:
+ return [BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series];
+ }
+ }
+
+ /// <summary>
/// Add additional DtoOptions.
/// </summary>
/// <remarks>
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index 5072f902da..d14c3a9343 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -5,7 +5,6 @@ using System.Security.Claims;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
-using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
@@ -31,15 +30,16 @@ public static class RequestHelpers
/// </summary>
/// <param name="sortBy">Sort By. Comma delimited string.</param>
/// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param>
+ /// <typeparam name="TSortBy">The type of the sort by field.</typeparam>
/// <returns>Order By.</returns>
- public static (ItemSortBy, SortOrder)[] GetOrderBy(IReadOnlyList<ItemSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
+ public static (TSortBy, SortOrder)[] GetOrderBy<TSortBy>(IReadOnlyList<TSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
{
if (sortBy.Count == 0)
{
- return Array.Empty<(ItemSortBy, SortOrder)>();
+ return Array.Empty<(TSortBy, SortOrder)>();
}
- var result = new (ItemSortBy, SortOrder)[sortBy.Count];
+ var result = new (TSortBy, SortOrder)[sortBy.Count];
var i = 0;
// Add elements which have a SortOrder specified
for (; i < requestedSortOrder.Count; i++)
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index bae2756303..6a6aac1327 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -144,6 +144,15 @@ public static class StreamingHelpers
mediaSource = liveStreamInfo.Item1;
state.DirectStreamProvider = liveStreamInfo.Item2;
+ // The requested live stream is no longer open. This commonly happens when a client keeps
+ // polling the HLS playlist (e.g. live.m3u8) after the stream was disposed because its
+ // consumer count dropped to zero. GetLiveStreamWithDirectStreamProvider returns a null
+ // MediaSource in that case, so return 404 instead of dereferencing it below.
+ if (mediaSource is null)
+ {
+ throw new ResourceNotFoundException($"The live stream with id {streamingRequest.LiveStreamId} could not be found or is no longer available.");
+ }
+
// Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate.
if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null)
{
diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
index ba24dc3864..f21e94a0fd 100644
--- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
+++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs
@@ -56,11 +56,11 @@ public class ActivityManager : IActivityManager
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- // TODO switch to LeftJoin in .NET 10.
- var entries = from a in dbContext.ActivityLogs
- join u in dbContext.Users on a.UserId equals u.Id into ugj
- from u in ugj.DefaultIfEmpty()
- select new ExpandedActivityLog { ActivityLog = a, Username = u.Username };
+ var entries = dbContext.ActivityLogs.LeftJoin(
+ dbContext.Users,
+ a => a.UserId,
+ u => u.Id,
+ (a, u) => new ExpandedActivityLog { ActivityLog = a, Username = u == null ? null : u.Username });
if (query.HasUserId is not null)
{
diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
index bcf348f8c6..d0d52a23fb 100644
--- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
+++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
@@ -213,8 +213,10 @@ namespace Jellyfin.Server.Implementations.Devices
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- dbContext.Devices.Remove(device);
- await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ await dbContext.Devices
+ .Where(d => d.Id == device.Id)
+ .ExecuteDeleteAsync()
+ .ConfigureAwait(false);
}
}
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
index a6dc5458ee..7c10a5dc77 100644
--- a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
using Jellyfin.Server.Implementations.StorageHelpers;
using Jellyfin.Server.Implementations.SystemBackupService;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.SystemBackupService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -33,6 +34,7 @@ public class BackupService : IBackupService
private readonly IServerApplicationPaths _applicationPaths;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
+ private readonly ILibraryManager _libraryManager;
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
{
AllowTrailingCommas = true,
@@ -50,13 +52,15 @@ public class BackupService : IBackupService
/// <param name="applicationPaths">The application paths.</param>
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
/// <param name="applicationLifetime">The SystemManager.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public BackupService(
ILogger<BackupService> logger,
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost applicationHost,
IServerApplicationPaths applicationPaths,
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
- IHostApplicationLifetime applicationLifetime)
+ IHostApplicationLifetime applicationLifetime,
+ ILibraryManager libraryManager)
{
_logger = logger;
_dbProvider = dbProvider;
@@ -64,6 +68,7 @@ public class BackupService : IBackupService
_applicationPaths = applicationPaths;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
_hostApplicationLifetime = applicationLifetime;
+ _libraryManager = libraryManager;
}
/// <inheritdoc/>
@@ -263,6 +268,14 @@ public class BackupService : IBackupService
/// <inheritdoc/>
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
{
+ // Creating a backup runs a database optimization and reads the entire database under a transaction, both of
+ // which heavily contend with an active library scan and could capture an inconsistent database state.
+ if (_libraryManager.IsScanRunning)
+ {
+ _logger.LogWarning("Cannot create a backup while a library scan is running.");
+ throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished.");
+ }
+
var manifest = new BackupManifest()
{
DateCreated = DateTime.UtcNow,
@@ -346,18 +359,39 @@ public class BackupService : IBackupService
jsonSerializer.WriteStartArray();
var set = entityType.ValueFactory().ConfigureAwait(false);
- await foreach (var item in set.ConfigureAwait(false))
+ var enumerator = set.GetAsyncEnumerator();
+ await using (enumerator)
{
- entities++;
- try
- {
- using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
- document.WriteTo(jsonSerializer);
- }
- catch (Exception ex)
+ while (true)
{
- _logger.LogError(ex, "Could not load entity {Entity}", item);
- throw;
+ bool hasNext;
+ try
+ {
+ hasNext = await enumerator.MoveNextAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not read next entity of type {Table}, the underlying data appears to be corrupt. Skipping this row and continuing backup; the affected database row should be inspected and fixed manually", entityType.SourceName);
+ continue;
+ }
+
+ if (!hasNext)
+ {
+ break;
+ }
+
+ var item = enumerator.Current;
+ entities++;
+ try
+ {
+ using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
+ document.WriteTo(jsonSerializer);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not load entity {Entity}", item);
+ throw;
+ }
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index d6ddf8f5c8..a4de9feb05 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -62,18 +62,21 @@ public sealed partial class BaseItemRepository
private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
- // Collapse duplicates sharing a presentation key (e.g. alternate versions) by picking
- // the min Id per group. Keep the grouped ids as an IQueryable sub-select; materializing
+ // Collapse duplicates sharing a presentation key (e.g. alternate versions), preferring the
+ // primary version (PrimaryVersionId is null) so detail pages and actions target it instead
+ // of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing
// to a List would inline one bound parameter per id and hit SQLite's variable cap.
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
{
- var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.Min(x => x.Id));
+ var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
+ .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (enableGroupByPresentationUniqueKey)
{
- var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.Min(x => x.Id));
+ var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey)
+ .Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (filter.GroupBySeriesPresentationUniqueKey)
@@ -444,6 +447,7 @@ public sealed partial class BaseItemRepository
if (filter.IncludeInheritedTags.Length > 0)
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -452,7 +456,10 @@ public sealed partial class BaseItemRepository
allowedTagItemIds.Contains(e.Id)
|| (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value))
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
- || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)));
+ || (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
+
+ // People don't carry the tags of the media they appear in and would never match
+ || e.Type == personTypeName);
}
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 3357f874d2..f19df6259e 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -513,13 +513,17 @@ public sealed partial class BaseItemRepository
if (filter.IsResumable.HasValue)
{
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
+ var userId = filter.User!.Id;
+ var isResumable = filter.IsResumable.Value;
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ // In-progress user data rows; alternate versions track their own progress.
+ var inProgress = context.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ IQueryable<Guid>? resumableSeriesIds = null;
if (hasSeries)
{
- var userId = filter.User!.Id;
- var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
- var isResumable = filter.IsResumable.Value;
-
// Aggregate per series in a single GROUP BY pass, instead of three full scans.
var seriesEpisodeStats = context.BaseItems
.AsNoTracking()
@@ -535,26 +539,49 @@ public sealed partial class BaseItemRepository
// A series is resumable if it has an in-progress episode,
// or if it has both played and unplayed episodes (partially watched).
- var resumableSeriesIds = seriesEpisodeStats
+ resumableSeriesIds = seriesEpisodeStats
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
.Select(s => s.SeriesId);
+ }
- // Non-series items: resumable if PlaybackPositionTicks > 0
- var resumableItemIds = context.UserData
- .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)
- .Select(ud => ud.ItemId);
-
- baseQuery = baseQuery.Where(e =>
- (e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable)
- || (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable));
+ if (isResumable)
+ {
+ // Resume queries surface the version that was actually played, which may be an alternate.
+ // Match each version on its own progress rather than coalescing onto the primary.
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+
+ baseQuery = hasSeries
+ ? baseQuery.Where(e =>
+ (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id))
+ || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
+ : baseQuery.Where(e => inProgressIds.Contains(e.Id));
+
+ // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
+ // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate,
+ // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by
+ // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row.
+ baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)));
}
else
{
- var resumableItemIds = context.UserData
- .Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0)
- .Select(ud => ud.ItemId);
- var isResumable = filter.IsResumable.Value;
- baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable);
+ // Not-resumable queries operate on primaries only.
+ var resumableMovieIds = inProgress
+ .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
+
+ baseQuery = hasSeries
+ ? baseQuery.Where(e =>
+ (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id))
+ || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id)))
+ : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id));
}
}
@@ -741,10 +768,13 @@ public sealed partial class BaseItemRepository
}
else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems)
{
- // Exclude alternate versions and owned non-extra items from general queries.
- // Alternate versions have PrimaryVersionId set (pointing to their primary).
+ // Exclude owned non-extra items from general queries.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
- baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ // Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries
+ // keep them so the actually-played version can surface instead of collapsing onto the primary.
+ baseQuery = filter.IsResumable == true
+ ? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null)
+ : baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
}
if (filter.OwnerIds.Length > 0)
@@ -1059,6 +1089,7 @@ public sealed partial class BaseItemRepository
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
+ var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -1069,6 +1100,9 @@ public sealed partial class BaseItemRepository
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
+ // People don't carry the tags of the media they appear in and would never match
+ || e.Type == personTypeName
+
// A playlist should be accessible to its owner regardless of allowed tags
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 94dedaeba8..57041276b7 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -167,6 +167,14 @@ public sealed partial class BaseItemRepository
return false;
}
+ // Resume queries surface the actually-played version (which may be an alternate sharing the
+ // primary's presentation key). The resumable filter already keeps one version per group, so
+ // presentation-key grouping must not collapse the surfaced version back onto the primary.
+ if (query.IsResumable == true)
+ {
+ return false;
+ }
+
if (query.GroupBySeriesPresentationUniqueKey)
{
return false;
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index 7c0cfe7c15..b10f7c527e 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService
descendantIds.Add(id);
}
+ // Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
+ // single parameter (json_each) rather than one SQL variable per id, which would otherwise
+ // overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
+ var ownerIds = descendantIds.ToArray();
var extraIds = context.BaseItems
- .Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value))
+ .Where(e => e.OwnerId.HasValue)
+ .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
.Select(e => e.Id)
.ToArray();
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index d327b218a9..aac85d0131 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -34,7 +34,14 @@ public static class OrderMapper
(ItemSortBy.AirTime, _) => e => e.SortName,
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
- (ItemSortBy.DatePlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.LastPlayedDate,
+ (ItemSortBy.DatePlayed, not null) => e =>
+ jellyfinDbContext.UserData
+ .Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
+ .Max(f => f.LastPlayedDate),
+ (ItemSortBy.DatePlayed, null) => e =>
+ jellyfinDbContext.UserData
+ .Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)
+ .Max(f => f.LastPlayedDate),
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 9be2eac4a1..81408d9aa8 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -170,7 +170,7 @@ namespace Jellyfin.Server.Implementations.Users
{
ThrowIfInvalidUsername(newName);
- if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
+ if (oldName.Equals(newName, StringComparison.Ordinal))
{
throw new ArgumentException("The new and old names must be different.");
}
@@ -616,6 +616,12 @@ namespace Jellyfin.Server.Implementations.Users
.SetProperty(f => f.LastActivityDate, date)
.SetProperty(f => f.LastLoginDate, date))
.ConfigureAwait(false);
+
+ // ExecuteUpdateAsync bypasses the change tracker, so keep the
+ // returned entity in sync. Otherwise SessionManager.LogSessionActivity
+ // saves this (stale) entity in full and reverts LastLoginDate.
+ user.LastActivityDate = date;
+ user.LastLoginDate = date;
}
await dbContext.Users
@@ -631,6 +637,7 @@ namespace Jellyfin.Server.Implementations.Users
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
+ dbContext.Update(user);
await dbContext.SaveChangesAsync()
.ConfigureAwait(false);
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
@@ -882,8 +889,20 @@ namespace Jellyfin.Server.Implementations.Users
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- dbContext.Remove(user.ProfileImage);
- await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ // Remove the tracked profile image loaded from the database instead of the
+ // detached instance on the passed in user. That instance can carry a stale,
+ // never-persisted (temporary) key, which makes EF Core throw when it is marked
+ // for deletion, leaving the profile image impossible to clear or replace.
+ var dbUser = await UserQuery(dbContext)
+ .AsTracking()
+ .FirstOrDefaultAsync(u => u.Id == user.Id)
+ .ConfigureAwait(false);
+ if (dbUser?.ProfileImage is not null)
+ {
+ dbContext.Remove(dbUser.ProfileImage);
+ dbUser.ProfileImage = null;
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ }
}
user.ProfileImage = null;
@@ -892,7 +911,7 @@ namespace Jellyfin.Server.Implementations.Users
internal static void ThrowIfInvalidUsername(string name)
{
- if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name))
+ if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal))
{
return;
}
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index 2aadedfa61..6db0edb237 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -48,6 +48,7 @@ namespace Jellyfin.Server.Extensions
c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API");
c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css");
c.RoutePrefix = "api-docs/swagger";
+ c.UseRequestInterceptor("""(req) => { req.headers['Authorization'] = `MediaBrowser Token=\"${req.headers['Authorization']}\"`; return req; }""");
})
.UseReDoc(c =>
{
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
index 9bf927bb95..a10be76e05 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -215,8 +215,11 @@ internal class JellyfinMigrationService
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
+ var migrationIndex = 0;
foreach (var item in migrations)
{
+ // Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
+ SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length));
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try
{
diff --git a/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs
index 0de775e03a..cbdc6efb44 100644
--- a/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/20250420140000_MigrateAuthenticationDb.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
@@ -79,9 +80,9 @@ namespace Jellyfin.Server.Migrations.Routines
foreach (var row in authenticatedDevices)
{
var dateCreatedStr = row.GetString(9);
- _ = DateTime.TryParse(dateCreatedStr, out var dateCreated);
+ _ = DateTime.TryParse(dateCreatedStr, CultureInfo.InvariantCulture, out var dateCreated);
var dateLastActivityStr = row.GetString(10);
- _ = DateTime.TryParse(dateLastActivityStr, out var dateLastActivity);
+ _ = DateTime.TryParse(dateLastActivityStr, CultureInfo.InvariantCulture, out var dateLastActivity);
if (row.IsDBNull(6))
{
diff --git a/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs
index f4dfa49068..e8eeb2da20 100644
--- a/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260113230000_CleanupOrphanedExtras.cs
@@ -76,25 +76,36 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
- // Batch-resolve items for metadata path cleanup, then delete all at once
- var itemsToDelete = new List<BaseItem>();
- foreach (var itemId in orphanedItemIds)
+ // Resolve items for metadata path cleanup, then delete in batches so we never issue one
+ // massive delete transaction and progress stays visible on large libraries.
+ _logger.LogInformation("Deleting {Count} orphaned extras...", orphanedItemIds.Count);
+ const int deleteBatchSize = 500;
+ var deletedSoFar = 0;
+ for (var offset = 0; offset < orphanedItemIds.Count; offset += deleteBatchSize)
{
- itemsToDelete.Add(BaseItemMapper.DeserializeBaseItem(
- new Database.Implementations.Entities.BaseItemEntity()
- {
- Id = itemId.Id,
- Path = itemId.Path,
- Type = itemId.Type
- },
- _logger,
- null,
- true)!);
- }
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset));
+ var itemsToDelete = batch
+ .Select(itemId => BaseItemMapper.DeserializeBaseItem(
+ new Database.Implementations.Entities.BaseItemEntity()
+ {
+ Id = itemId.Id,
+ Path = itemId.Path,
+ Type = itemId.Type
+ },
+ _logger,
+ null,
+ true)!)
+ .ToList();
- _libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
+
+ deletedSoFar += batch.Count;
+ _logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count);
+ }
- _logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count);
+ _logger.LogInformation("Successfully removed {Count} orphaned extras", orphanedItemIds.Count);
}
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs
index 0baf261a2e..e34182fd5d 100644
--- a/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260115120000_FixIncorrectOwnerIdRelationships.cs
@@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
if (allIdsToDelete.Count > 0)
{
- // Batch-resolve items for metadata path cleanup, then delete all at once
- var itemsToDelete = allIdsToDelete
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
-
- // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
- var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
- var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
- if (unresolvedIds.Count > 0)
+ _logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count);
+
+ // Delete in batches so progress is visible (item resolution and deletion can take a
+ // long time on large libraries) and so we never issue one massive delete transaction.
+ const int deleteBatchSize = 500;
+ var deletedSoFar = 0;
+ for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize)
{
- _persistenceService.DeleteItem(unresolvedIds);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset));
+
+ // Resolve items for metadata path cleanup, then delete this batch
+ var itemsToDelete = batchIds
+ .Select(id => _libraryManager.GetItemById(id))
+ .Where(item => item is not null)
+ .ToList();
+ if (itemsToDelete.Count > 0)
+ {
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
+ }
+
+ // Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
+ var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
+ var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
+ if (unresolvedIds.Count > 0)
+ {
+ _persistenceService.DeleteItem(unresolvedIds);
+ }
+
+ deletedSoFar += batchIds.Count;
+ _logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count);
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs
index f598848465..bff6ebbfb0 100644
--- a/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260508120000_MergeDuplicateMusicArtists.cs
@@ -182,23 +182,35 @@ public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
// Fall back to the persistence service for any items the LibraryManager can't resolve.
- var itemsToDelete = idsToDelete
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- if (itemsToDelete.Count > 0)
+ // Delete in batches so we never issue one massive delete transaction and progress stays visible.
+ _logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count);
+ const int deleteBatchSize = 500;
+ var deletedSoFar = 0;
+ for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
{
- _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
- }
+ cancellationToken.ThrowIfCancellationRequested();
- var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
- var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
- if (unresolvedIds.Count > 0)
- {
- _persistenceService.DeleteItem(unresolvedIds);
- }
+ var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
+
+ var itemsToDelete = batchIds
+ .Select(id => _libraryManager.GetItemById(id))
+ .Where(item => item is not null)
+ .ToList();
+ if (itemsToDelete.Count > 0)
+ {
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
+ }
+
+ var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
+ var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
+ if (unresolvedIds.Count > 0)
+ {
+ _persistenceService.DeleteItem(unresolvedIds);
+ }
- _logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count);
+ deletedSoFar += batchIds.Count;
+ _logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
+ }
}
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs
index 10433599fa..f28c804d26 100644
--- a/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260508130000_MergeDuplicatePeople.cs
@@ -184,23 +184,35 @@ public class MergeDuplicatePeople : IAsyncMigrationRoutine
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
// %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
- var itemsToDelete = idsToDelete
- .Select(id => _libraryManager.GetItemById(id))
- .Where(item => item is not null)
- .ToList();
- if (itemsToDelete.Count > 0)
+ // Delete in batches so we never issue one massive delete transaction and progress stays visible.
+ _logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count);
+ const int deleteBatchSize = 500;
+ var deletedSoFar = 0;
+ for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
{
- _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
- }
+ cancellationToken.ThrowIfCancellationRequested();
- var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
- var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
- if (unresolvedIds.Count > 0)
- {
- _persistenceService.DeleteItem(unresolvedIds);
- }
+ var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
+
+ var itemsToDelete = batchIds
+ .Select(id => _libraryManager.GetItemById(id))
+ .Where(item => item is not null)
+ .ToList();
+ if (itemsToDelete.Count > 0)
+ {
+ _libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
+ }
+
+ var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
+ var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
+ if (unresolvedIds.Count > 0)
+ {
+ _persistenceService.DeleteItem(unresolvedIds);
+ }
- _logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count);
+ deletedSoFar += batchIds.Count;
+ _logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
+ }
}
private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index af0d424aad..12f92efb35 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -133,10 +133,12 @@ namespace Jellyfin.Server
}
}
+ SetupServer.ReportActivity(StartupActivity.CheckingStorage);
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
StartupHelpers.PerformStaticInitialization();
+ SetupServer.ReportActivity(StartupActivity.Initializing);
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
do
@@ -195,6 +197,7 @@ namespace Jellyfin.Server
if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
{
+ SetupServer.ReportActivity(StartupActivity.RestoringBackup);
await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
_restoreFromBackup = null;
_restartOnShutdown = true;
@@ -202,9 +205,13 @@ namespace Jellyfin.Server
}
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
+ SetupServer.ReportActivity(StartupActivity.PreparingMigrations);
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
+ // "Preparing migrations" carries through the DB read; per-migration progress is reported
+ // as "Running migration X of Y" from inside the step once the pending set is known.
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
+ SetupServer.ReportActivity(StartupActivity.InitializingServices);
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
_appHost = appHost;
diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
index 37bb1abe71..598de5aa5f 100644
--- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs
+++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
@@ -14,7 +14,6 @@ using Jellyfin.Server.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
-using MediaBrowser.Model.IO;
using MediaBrowser.Model.System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -25,9 +24,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
-using Morestachio;
-using Morestachio.Framework.IO.SingleStream;
-using Morestachio.Rendering;
using Serilog;
using ILogger = Microsoft.Extensions.Logging.ILogger;
@@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable
private readonly ILoggerFactory _loggerFactory;
private readonly IConfiguration _startupConfiguration;
private readonly ServerConfigurationManager _configurationManager;
- private IRenderer? _startupUiRenderer;
+ private static volatile string _currentActivity = StartupActivity.Starting;
+ private StartupUiRenderer? _startupUiRenderer;
private IHost? _startupServer;
private bool _disposed;
private bool _isUnhealthy;
@@ -77,6 +74,12 @@ public sealed class SetupServer : IDisposable
internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
/// <summary>
+ /// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the
+ /// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details.
+ /// </summary>
+ internal static string CurrentActivity => _currentActivity;
+
+ /// <summary>
/// Gets a value indicating whether Startup server is currently running.
/// </summary>
public bool IsAlive { get; internal set; }
@@ -87,64 +90,9 @@ public sealed class SetupServer : IDisposable
/// <returns>A Task.</returns>
public async Task RunAsync()
{
- var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
- _startupUiRenderer = (await ParserOptionsBuilder.New()
- .WithTemplate(fileTemplate)
- .WithFormatter(
- (Version version, int arg) =>
- {
- // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
- return version.ToString(arg);
- },
- "ToString")
- .WithFormatter(
- (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
- {
- if (children.Any())
- {
- var maxLevel = logEntry.LogLevel;
- var stack = new Stack<StartupLogTopic>(children);
-
- while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
- {
- maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
- foreach (var child in logEntry.Children)
- {
- stack.Push(child);
- }
- }
-
- return maxLevel;
- }
-
- return logEntry.LogLevel;
- },
- "FormatLogLevel")
- .WithFormatter(
- (LogLevel logLevel) =>
- {
- switch (logLevel)
- {
- case LogLevel.Trace:
- case LogLevel.Debug:
- case LogLevel.None:
- return "success";
- case LogLevel.Information:
- return "info";
- case LogLevel.Warning:
- return "warn";
- case LogLevel.Error:
- return "danger";
- case LogLevel.Critical:
- return "danger-strong";
- }
-
- return string.Empty;
- },
- "ToString")
- .BuildAndParseAsync()
- .ConfigureAwait(false))
- .CreateCompiledRenderer();
+ ReportActivity(StartupActivity.Starting);
+ _startupUiRenderer = await StartupUiRenderer.CreateAsync(
+ Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
ThrowIfDisposed();
var retryAfterValue = TimeSpan.FromSeconds(5);
@@ -257,13 +205,14 @@ public sealed class SetupServer : IDisposable
new Dictionary<string, object>()
{
{ "isInReportingMode", _isUnhealthy },
+ { "currentActivity", CurrentActivity },
{ "retryValue", retryAfterValue },
{ "version", version },
{ "logs", startupLogEntries },
{ "networkManagerReady", networkManager is not null },
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
},
- new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
+ context.Response.BodyWriter.AsStream())
.ConfigureAwait(false);
});
});
@@ -309,6 +258,16 @@ public sealed class SetupServer : IDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
}
+ /// <summary>
+ /// Reports the current startup activity shown to all clients in the startup UI header.
+ /// Only pass generic, non-identifying text from <see cref="StartupActivity"/>.
+ /// </summary>
+ /// <param name="activity">A generic description such as <see cref="StartupActivity.PreparingMigrations"/>.</param>
+ internal static void ReportActivity(string activity)
+ {
+ _currentActivity = activity;
+ }
+
internal void SoftStop()
{
_isUnhealthy = true;
diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs
new file mode 100644
index 0000000000..888cc617d4
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs
@@ -0,0 +1,41 @@
+using System.Globalization;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <summary>
+/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup.
+/// These are shown in the always-visible header of the startup UI to <b>unauthenticated</b> clients, so every
+/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.).
+/// </summary>
+public static class StartupActivity
+{
+ /// <summary>The default state before any work has been reported.</summary>
+ public const string Starting = "Starting up";
+
+ /// <summary>Validating that the configured storage locations are usable.</summary>
+ public const string CheckingStorage = "Checking storage";
+
+ /// <summary>Bringing up the migration subsystem and running early startup checks.</summary>
+ public const string Initializing = "Initializing server";
+
+ /// <summary>Preparing the system for migrations (e.g. taking safety backups).</summary>
+ public const string PreparingMigrations = "Preparing migrations";
+
+ /// <summary>Restoring from a backup.</summary>
+ public const string RestoringBackup = "Restoring backup";
+
+ /// <summary>Bringing up core services and plugins.</summary>
+ public const string InitializingServices = "Initializing services";
+
+ /// <summary>Running the final startup tasks.</summary>
+ public const string FinishingStartup = "Finishing startup";
+
+ /// <summary>
+ /// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed.
+ /// </summary>
+ /// <param name="current">The 1-based index of the migration currently running.</param>
+ /// <param name="total">The total number of migrations in this batch.</param>
+ /// <returns>A generic progress description.</returns>
+ public static string Migration(int current, int total)
+ => string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total);
+}
diff --git a/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs
new file mode 100644
index 0000000000..db07b9d8c1
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+using Morestachio;
+using Morestachio.Framework.IO.SingleStream;
+using Morestachio.Rendering;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <summary>
+/// Compiles and renders the startup UI Morestachio template.
+/// Shared by the live <see cref="SetupServer"/> and the standalone startup UI preview tool so both
+/// exercise the exact same template and formatters.
+/// </summary>
+public sealed class StartupUiRenderer
+{
+ private readonly IRenderer _renderer;
+
+ private StartupUiRenderer(IRenderer renderer)
+ {
+ _renderer = renderer;
+ }
+
+ /// <summary>
+ /// Compiles the startup UI template located at <paramref name="templatePath"/>.
+ /// </summary>
+ /// <param name="templatePath">The full path to the <c>index.mstemplate.html</c> template.</param>
+ /// <returns>A ready to use <see cref="StartupUiRenderer"/>.</returns>
+ public static async Task<StartupUiRenderer> CreateAsync(string templatePath)
+ {
+ var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false);
+ var renderer = (await ParserOptionsBuilder.New()
+ .WithTemplate(fileTemplate)
+ .WithFormatter(
+ (Version version, int arg) =>
+ {
+ // version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
+ return version.ToString(arg);
+ },
+ "ToString")
+ .WithFormatter(
+ (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
+ {
+ if (children.Any())
+ {
+ var maxLevel = logEntry.LogLevel;
+ var stack = new Stack<StartupLogTopic>(children);
+
+ while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
+ {
+ maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
+ foreach (var child in logEntry.Children)
+ {
+ stack.Push(child);
+ }
+ }
+
+ return maxLevel;
+ }
+
+ return logEntry.LogLevel;
+ },
+ "FormatLogLevel")
+ .WithFormatter(
+ (LogLevel logLevel) =>
+ {
+ switch (logLevel)
+ {
+ case LogLevel.Trace:
+ case LogLevel.Debug:
+ case LogLevel.None:
+ return "success";
+ case LogLevel.Information:
+ return "info";
+ case LogLevel.Warning:
+ return "warn";
+ case LogLevel.Error:
+ return "danger";
+ case LogLevel.Critical:
+ return "danger-strong";
+ }
+
+ return string.Empty;
+ },
+ "ToString")
+ .BuildAndParseAsync()
+ .ConfigureAwait(false))
+ .CreateCompiledRenderer();
+
+ return new StartupUiRenderer(renderer);
+ }
+
+ /// <summary>
+ /// Renders the template with the provided model into the target stream.
+ /// </summary>
+ /// <param name="model">The values made available to the template.</param>
+ /// <param name="output">The stream the rendered HTML is written to.</param>
+ /// <returns>A Task.</returns>
+ public Task RenderAsync(IDictionary<string, object> model, Stream output)
+ {
+ return _renderer.RenderAsync(
+ model,
+ new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions));
+ }
+}
diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
index 5706ce1fac..9c12762c31 100644
--- a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
+++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
@@ -1,189 +1,469 @@
<!DOCTYPE html>
-<html>
+<html lang="en">
<head>
<meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="color-scheme" content="dark" />
+ <meta name="theme-color" content="#202020" />
<title>
{{#IF isInReportingMode}}
{{/IF}}
- Jellyfin Startup
+ Jellyfin
</title>
<style>
+ /* Noto Sans (latin), matching the Jellyfin web client font. Embedded so the page renders
+ identically before the web client is available. */
+ @font-face {
+ font-family: "Noto Sans";
+ font-style: normal;
+ font-display: swap;
+ font-weight: 400;
+ src: url(data:font/woff2;base64,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) format("woff2");
+ }
+
+ @font-face {
+ font-family: "Noto Sans";
+ font-style: normal;
+ font-display: swap;
+ font-weight: 700;
+ src: url(data:font/woff2;base64,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) format("woff2");
+ }
+
+ :root {
+ --jf-background: #101010;
+ --jf-paper: #202020;
+ --jf-text: #fff;
+ --jf-text-secondary: rgba(255, 255, 255, 0.7);
+ --jf-primary: #00a4dc;
+ --jf-primary-dark: #00729a;
+ --jf-divider: rgba(255, 255, 255, 0.12);
+ --jf-error: #d15353;
+ --jf-warn: #f2b01e;
+ --jf-success: #5cbd5c;
+ --jf-scrollbar-thumb: #3b3b3b;
+ }
+
* {
- font-family: sans-serif;
+ box-sizing: border-box;
+ scrollbar-width: thin;
+ scrollbar-color: var(--jf-scrollbar-thumb) var(--jf-paper);
+ }
+
+ html {
+ height: 100%;
+ font-size: 93%;
+ line-height: 1.35;
}
- .flex-row {
+ body {
+ margin: 0;
+ padding: 0 0 50px;
+ min-height: 100%;
+ background-color: var(--jf-background);
+ color: var(--jf-text);
+ font-family: "Noto Sans", "Noto Sans HK", "Noto Sans JP", "Noto Sans KR", "Noto Sans SC", "Noto Sans TC", sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
+
+ ::-webkit-scrollbar {
+ width: 0.4em;
+ height: 0.4em;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: var(--jf-paper);
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background: #888;
+ border-radius: 2px;
+ }
+
+ a {
+ color: var(--jf-primary);
+ text-decoration: none;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ }
+
+ .page {
+ max-width: 54em;
+ margin: 0 auto;
+ padding: 4.5em 3.3% 0;
+ }
+
+ .logo {
+ display: block;
+ width: 15em;
+ max-width: 70%;
+ height: auto;
+ margin: 0 auto 2.25em;
+ }
+
+ .status {
display: flex;
flex-direction: row;
- flex-wrap: nowrap;
+ align-items: center;
justify-content: center;
+ gap: 0.75em;
+ text-align: center;
+ margin-bottom: 1.5em;
+ }
+
+ .status .status-text {
+ margin: 0;
+ font-weight: 400;
+ font-size: 1.25em;
+ color: var(--jf-text);
+ }
+
+ .status.is-error .status-text {
+ color: var(--jf-error);
+ }
+
+ /* Buttons — matching the web client's emby-button styles. */
+ .jf-button {
+ display: inline-flex;
align-items: center;
- align-content: normal;
+ justify-content: center;
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0.9em 1em;
+ border: 0;
+ border-radius: 0.2em;
+ font-family: inherit;
+ font-size: inherit;
+ font-weight: 600;
+ line-height: 1.35;
+ cursor: pointer;
+ outline: none;
+ text-decoration: none;
+ transition: 0.2s;
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+ }
+
+ .jf-button-primary {
+ background: var(--jf-primary);
+ color: rgba(0, 0, 0, 0.87);
+ }
+
+ .jf-button-primary:hover,
+ .jf-button-primary:focus {
+ background: var(--jf-primary-dark);
+ }
+
+ .jf-button-secondary {
+ background: #424242;
+ color: var(--jf-text-secondary);
+ }
+
+ .jf-button-secondary:hover,
+ .jf-button-secondary:focus {
+ background: #616161;
}
- .flex-col {
+ /* Redirect countdown shown once the server is ready. */
+ .redirect-bar {
display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
- justify-content: center;
+ flex-direction: row;
align-items: center;
- align-content: normal;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 1em;
+ margin-bottom: 1.5em;
+ text-align: center;
+ }
+
+ .redirect-countdown {
+ color: var(--jf-text-secondary);
+ }
+
+ /* Material (MDL) spinner — the same one the web client uses while loading. */
+ .mdl-spinner {
+ position: relative;
+ display: inline-block;
+ flex: 0 0 auto;
+ width: 1.95em;
+ height: 1.95em;
+ font-size: 0.8em;
+ animation: mdl-spinner__container-rotate 1568.23529412ms linear infinite;
+ }
+
+ @keyframes mdl-spinner__container-rotate {
+ to {
+ transform: rotate(360deg);
+ }
}
- header {
- height: 5rem;
+ .mdl-spinner__layer {
+ position: absolute;
width: 100%;
+ height: 100%;
+ opacity: 0;
+ border-color: var(--jf-primary);
}
- header svg {
- height: 3rem;
- width: 9rem;
- margin-right: 1rem;
+ .mdl-spinner__layer-1 {
+ animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-1-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
- /* ol.action-list {
- list-style-type: none;
- position: relative;
- } */
+ .mdl-spinner__layer-2 {
+ animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-2-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
+ }
- ol.action-list * {
- font-family: monospace;
- font-weight: 300;
- font-size: clamp(18px, 100vw / var(--width), 20px);
- font-feature-settings: 'onum', 'pnum';
- line-height: 1.8;
- -webkit-text-size-adjust: none;
+ .mdl-spinner__layer-3 {
+ animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-3-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
- /*
- ol.action-list li {
- padding-top: .5rem;
+ .mdl-spinner__layer-4 {
+ animation: mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both, mdl-spinner__layer-4-fade-in-out 5332ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
- ol.action-list li::before {
- position: absolute;
- left: -0.8em;
- font-size: 1.1em;
- } */
+ @keyframes mdl-spinner__fill-unfill-rotate {
+ 12.5% { transform: rotate(135deg); }
+ 25% { transform: rotate(270deg); }
+ 37.5% { transform: rotate(405deg); }
+ 50% { transform: rotate(540deg); }
+ 62.5% { transform: rotate(675deg); }
+ 75% { transform: rotate(810deg); }
+ 87.5% { transform: rotate(945deg); }
+ to { transform: rotate(1080deg); }
+ }
- /* Attribution as heavily inspired by: https://iamkate.com/code/tree-views/ */
- .action-list {
- --spacing: 1.4rem;
- --radius: 14px;
+ @keyframes mdl-spinner__layer-1-fade-in-out {
+ from { opacity: 0.99; }
+ 25% { opacity: 0.99; }
+ 26% { opacity: 0; }
+ 89% { opacity: 0; }
+ 90% { opacity: 0.99; }
+ 100% { opacity: 0.99; }
}
- .action-list li {
- display: block;
+ @keyframes mdl-spinner__layer-2-fade-in-out {
+ from { opacity: 0; }
+ 15% { opacity: 0; }
+ 25% { opacity: 0.99; }
+ 50% { opacity: 0.99; }
+ 51% { opacity: 0; }
+ }
+
+ @keyframes mdl-spinner__layer-3-fade-in-out {
+ from { opacity: 0; }
+ 40% { opacity: 0; }
+ 50% { opacity: 0.99; }
+ 75% { opacity: 0.99; }
+ 76% { opacity: 0; }
+ }
+
+ @keyframes mdl-spinner__layer-4-fade-in-out {
+ from { opacity: 0; }
+ 65% { opacity: 0; }
+ 75% { opacity: 0.99; }
+ 90% { opacity: 0.99; }
+ 100% { opacity: 0; }
+ }
+
+ .mdl-spinner__circle {
+ box-sizing: border-box;
+ height: 100%;
+ border-width: 0.21em;
+ border-style: solid;
+ border-color: inherit;
+ border-bottom-color: transparent !important;
+ border-radius: 50%;
+ animation: none;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ }
+
+ .mdl-spinner__circle-clipper {
+ display: inline-block;
position: relative;
- padding-left: calc(2 * var(--spacing) - var(--radius) - 1px);
+ width: 50%;
+ height: 100%;
+ overflow: hidden;
+ border-color: inherit;
}
- .action-list ul {
- margin-left: calc(var(--radius) - var(--spacing));
- padding-left: 0;
+ .mdl-spinner__circle-clipper .mdl-spinner__circle {
+ width: 200%;
}
- .action-list ul li {
- border-left: 2px solid #ddd;
+ .mdl-spinner__circleLeft {
+ border-right-color: transparent !important;
+ transform: rotate(129deg);
+ animation: mdl-spinner__left-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
- .action-list ul li:last-child {
- border-color: transparent;
+ .mdl-spinner__circleRight {
+ left: -100%;
+ border-left-color: transparent !important;
+ transform: rotate(-129deg);
+ animation: mdl-spinner__right-spin 1333ms cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
- .action-list ul li::before {
- content: '';
- display: block;
- position: absolute;
- top: calc(var(--spacing) / -2);
- left: -2px;
- width: calc(var(--spacing) + 2px);
- height: calc(var(--spacing) + 1px);
- border: solid #ddd;
- border-width: 0 0 2px 2px;
+ @keyframes mdl-spinner__left-spin {
+ from { transform: rotate(130deg); }
+ 50% { transform: rotate(-5deg); }
+ to { transform: rotate(130deg); }
}
- .action-list summary {
- display: block;
- cursor: pointer;
+ @keyframes mdl-spinner__right-spin {
+ from { transform: rotate(-130deg); }
+ 50% { transform: rotate(5deg); }
+ to { transform: rotate(-130deg); }
}
- .action-list summary::marker,
- .action-list summary::-webkit-details-marker {
- display: none;
+ /* Startup log — a paperList-style surface. */
+ .logs-panel {
+ background-color: var(--jf-paper);
+ border-radius: 0.2em;
+ box-shadow: 0 0.0725em 0.29em 0 rgba(0, 0, 0, 0.37);
+ padding: calc(0.8em - 4px) 1.2em 1em;
}
- .action-list summary:focus {
- outline: none;
+ .logs-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 1em;
+ margin-bottom: 0.5em;
+ padding-bottom: 0.5em;
+ border-bottom: 1px solid var(--jf-divider);
}
- .action-list summary:focus-visible {
- outline: 1px dotted #000;
+ .logs-header h2 {
+ margin: 0;
+ font-weight: 400;
+ font-size: 1.17em;
}
- .action-list li::after,
- .action-list summary::before {
- content: '';
- display: block;
- position: absolute;
- top: calc(var(--spacing) / 2 - var(--radius) + 4px);
- left: calc(var(--spacing) - var(--radius) - -5px);
+ .download-logs {
+ flex: 0 0 auto;
+ font-size: 0.9em;
+ font-weight: 600;
}
- .action-list summary::before {
- z-index: 1;
- /* background: #696 url('expand-collapse.svg') 0 0; */
+ .logs-scroll {
+ overflow-y: auto;
}
- .action-list details[open]>summary::before {
- background-position: calc(-2 * var(--radius)) 0;
+ .action-list,
+ .action-list ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
}
- .action-list li.danger-item::after,
- .action-list li.danger-strong-item::after {
- content: '❌';
+ .action-list {
+ font-size: 0.92em;
}
- ol.action-list li span.danger-strong-item {
- text-decoration-style: solid;
- text-decoration-color: red;
- text-decoration-line: underline;
+ .action-list li {
+ position: relative;
+ padding: 0.18em 0;
}
- ol.action-list li.warn-item::after {
- content: '⚠️';
+ .action-list summary,
+ .action-list li > span {
+ display: block;
+ color: var(--jf-text);
}
- ol.action-list li.success-item::after {
- content: '✅';
+ .action-list summary {
+ cursor: pointer;
+ list-style: none;
+ }
+
+ .action-list summary::-webkit-details-marker {
+ display: none;
}
- ol.action-list li.info-item::after {
- content: '🔹';
+ /* Severity dot, colored from the theme palette. */
+ .action-list summary::before,
+ .action-list li > span::before {
+ content: "";
+ display: inline-block;
+ flex: 0 0 auto;
+ width: 0.5em;
+ height: 0.5em;
+ margin-right: 0.7em;
+ border-radius: 50%;
+ background: var(--dot, var(--jf-text-secondary));
}
- /* End Attribution */
+ .action-list li.success-item { --dot: var(--jf-success); }
+ .action-list li.info-item { --dot: var(--jf-primary); }
+ .action-list li.warn-item { --dot: var(--jf-warn); }
+ .action-list li.danger-item,
+ .action-list li.danger-strong-item { --dot: var(--jf-error); }
+
+ .action-list li.warn-item > span,
+ .action-list li.warn-item > details > summary { color: var(--jf-warn); }
+
+ .action-list li.danger-item > span,
+ .action-list li.danger-item > details > summary,
+ .action-list li.danger-strong-item > span,
+ .action-list li.danger-strong-item > details > summary { color: var(--jf-error); }
+
+ .action-list li.danger-strong-item > span { font-weight: 700; }
+
+ /* Nested groups: indent with a divider guide line. */
+ .action-list ul {
+ margin-left: 0.25em;
+ padding-left: 1.1em;
+ border-left: 1px solid var(--jf-divider);
+ }
</style>
</head>
<body>
- <div>
- <header class="flex-row">
-
- {{^IF isInReportingMode}}
- <p>Jellyfin Server {{version.ToString(2)}} still starting. Please wait.</p>
- {{#ELSE}}
- <p>Jellyfin Server {{version.ToString(2)}} has encountered an error and was not able to start.</p>
- {{/ELSE}}
- {{/IF}}
-
- {{#IF localNetworkRequest}}
- <p style="margin-left: 1rem;">You can download the current log file <a href='/startup/logger'
- target="_blank">here</a>.</p>
- {{/IF}}
- </header>
+ <div class="page">
+ <svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 251 72" fill="none" role="img" aria-label="Jellyfin">
+ <g clip-path="url(#a)">
+ <path fill="url(#b)" d="M24.212 49.158C22.66 46.042 32.838 27.588 36 27.588c3.167.002 13.323 18.488 11.788 21.57-1.534 3.082-22.025 3.116-23.576 0" />
+ <path fill="url(#c)" fill-rule="evenodd" d="M.482 64.995C-4.195 55.605 26.477 0 36 0c9.533 0 40.153 55.713 35.527 64.995s-66.368 9.39-71.045 0m12.254-8.148c3.064 6.152 43.518 6.084 46.548 0 3.03-6.086-17.032-42.586-23.275-42.586S9.671 50.694 12.736 56.847" clip-rule="evenodd" />
+ <path fill="#fff" d="M225.22 56c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.219-.218c-.054-.107-.054-.247-.054-.527V26.8c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.183c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v2.895a7.9 7.9 0 0 1 3.419-3.254q2.261-1.103 5.074-1.103 3.308 0 5.845 1.434a10.1 10.1 0 0 1 4.026 4.026q1.434 2.536 1.434 5.9V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.218c-.107.055-.247.055-.527.055h-5.625c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527V38.408q0-2.978-1.709-4.688-1.654-1.764-4.357-1.764-2.702 0-4.412 1.764-1.654 1.766-1.654 4.688V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm-11.54-33.363c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527v-6.121c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527v6.12c0 .28 0 .42-.054.528a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm0 33.363c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.219c-.055-.107-.055-.247-.055-.527V26.8c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h5.624c.28 0 .42 0 .527.055a.5.5 0 0 1 .219.218c.054.107.054.247.054.527v28.4c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-16.712-.054c.107.054.247.054.527.054h5.625c.28 0 .42 0 .526-.054a.5.5 0 0 0 .219-.219c.055-.107.055-.247.055-.527V32.452h5.872c.28 0 .42 0 .527-.054a.5.5 0 0 0 .219-.219c.054-.107.054-.247.054-.527V26.8c0-.28 0-.42-.054-.527a.5.5 0 0 0-.219-.218c-.107-.055-.247-.055-.527-.055h-5.872v-.992q0-2.261 1.323-3.31 1.379-1.102 3.75-1.102.454 0 .939.044c.345.031.518.047.634-.004a.48.48 0 0 0 .241-.22c.061-.111.061-.274.061-.6V15.39c0-.304 0-.457-.061-.589a.7.7 0 0 0-.248-.284c-.122-.078-.261-.097-.537-.136a14.5 14.5 0 0 0-1.966-.126q-5.184 0-8.273 2.812t-3.088 7.942V26H186.53c-.3 0-.451 0-.58.05a.75.75 0 0 0-.296.205c-.091.104-.143.244-.248.526l-7.43 19.9-7.483-19.903c-.105-.28-.158-.42-.249-.524a.75.75 0 0 0-.296-.205c-.129-.049-.279-.049-.578-.049h-5.769c-.394 0-.591 0-.717.083a.5.5 0 0 0-.213.314c-.031.147.041.33.186.697L174.281 56l-.661 1.6q-.883 1.874-2.041 3.033-1.103 1.158-3.584 1.158-.883 0-1.875-.166a13 13 0 0 1-.73-.1c-.389-.066-.584-.099-.709-.053a.47.47 0 0 0-.26.22c-.066.116-.066.298-.066.663v4.329c0 .243 0 .365.045.481a.7.7 0 0 0 .189.266c.095.081.194.116.392.185q.684.24 1.47.351 1.158.22 2.371.22 4.246 0 7.059-2.426 2.867-2.37 4.577-6.728l10.517-26.58h5.72V55.2c0 .28 0 .42.055.527a.5.5 0 0 0 .218.219M154.363 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.219c-.107.054-.247.054-.527.054zm-11.621 0c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-18.132.662q-4.632-.001-8.107-2.096a14.6 14.6 0 0 1-5.404-5.68q-1.93-3.585-1.93-7.942 0-4.522 1.93-7.996 1.985-3.53 5.349-5.57 3.42-2.04 7.61-2.04 4.688 0 7.942 2.04 3.253 1.986 4.963 5.294 1.71 3.309 1.709 7.335 0 .828-.11 1.654-.031.45-.12.841c-.037.165-.055.247-.115.33a.55.55 0 0 1-.208.168c-.095.04-.194.04-.393.04h-21.057q.33 3.309 2.537 5.294 2.205 1.986 5.459 1.985 2.482 0 4.191-1.047a8.2 8.2 0 0 0 2.206-1.986c.241-.316.362-.474.484-.542a.6.6 0 0 1 .352-.083c.139.006.296.083.608.236l4.269 2.094c.239.118.359.176.431.275a.52.52 0 0 1 .098.298c0 .122-.058.231-.172.45q-1.432 2.742-4.526 4.607-3.419 2.04-7.996 2.04m-.552-25.368q-2.702 0-4.687 1.654-1.93 1.6-2.537 4.577h14.118q-.22-2.757-2.151-4.466-1.875-1.765-4.743-1.765M90.801 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.218C90 55.62 90 55.48 90 55.2v-5.294c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h1.572q2.646 0 4.19-1.489 1.6-1.545 1.6-4.08V15.715c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.956c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v27.546q0 3.804-1.655 6.672-1.599 2.868-4.632 4.467-2.979 1.6-7.06 1.6z" />
+ </g>
+ <defs>
+ <linearGradient id="b" x1="12" x2="71.999" y1="30.001" y2="63.002" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#aa5cc3" />
+ <stop offset="1" stop-color="#00a4dc" />
+ </linearGradient>
+ <linearGradient id="c" x1="12" x2="71.999" y1="29.999" y2="63.001" gradientUnits="userSpaceOnUse">
+ <stop stop-color="#aa5cc3" />
+ <stop offset="1" stop-color="#00a4dc" />
+ </linearGradient>
+ <clipPath id="a">
+ <path fill="#fff" d="M0 0h251v72H0z" />
+ </clipPath>
+ </defs>
+ </svg>
+
+ {{^IF isInReportingMode}}
+ <div class="status">
+ <div class="mdl-spinner" dir="ltr" aria-hidden="true">
+ <div class="mdl-spinner__layer mdl-spinner__layer-1"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div>
+ <div class="mdl-spinner__layer mdl-spinner__layer-2"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div>
+ <div class="mdl-spinner__layer mdl-spinner__layer-3"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div>
+ <div class="mdl-spinner__layer mdl-spinner__layer-4"><div class="mdl-spinner__circle-clipper mdl-spinner__left"><div class="mdl-spinner__circle mdl-spinner__circleLeft"></div></div><div class="mdl-spinner__circle-clipper mdl-spinner__right"><div class="mdl-spinner__circle mdl-spinner__circleRight"></div></div></div>
+ </div>
+ <p class="status-text">Jellyfin is still starting. Please wait&hellip; {{currentActivity}}</p>
+ </div>
+ {{#ELSE}}
+ <div class="status is-error">
+ <p class="status-text">Jellyfin has encountered an error and was not able to start.</p>
+ </div>
+ {{/ELSE}}
+ {{/IF}}
{{#DECLARE LogEntry |--}}
{{#LET children = Children}}
@@ -192,7 +472,7 @@
<details open>
<summary>{{DateOfCreation}} - {{Content}}</summary>
<ul class="action-list">
- {{--| #EACH children.Reverse() |-}}
+ {{--| #EACH children |-}}
{{#IMPORT 'LogEntry'}}
{{--| /EACH |-}}
</ul>
@@ -205,31 +485,175 @@
{{--| /DECLARE}}
{{#IF localNetworkRequest}}
- <div class="flex-col">
- <ol class="action-list">
- {{#FOREACH log IN logs.Reverse()}}
- {{#IMPORT 'LogEntry' #WITH log}}
- {{/FOREACH}}
- </ol>
+ <div class="logs-panel">
+ <div class="logs-header">
+ <h2>Startup log</h2>
+ <a class="download-logs" href='/startup/logger' target="_blank">Download logs</a>
+ </div>
+ <div class="logs-scroll" id="logs-scroll">
+ <ol class="action-list">
+ {{#FOREACH log IN logs}}
+ {{#IMPORT 'LogEntry' #WITH log}}
+ {{/FOREACH}}
+ </ol>
+ </div>
</div>
- {{#ELSE}}
- {{#IF networkManagerReady}}
- <p>Please visit this page from your local network to view detailed startup logs.</p>
- {{#ELSE}}
- <p>Initializing network settings. Please wait.</p>
- {{/ELSE}}
- {{/IF}}
- {{/ELSE}}
{{/IF}}
</div>
+ <script>
+ (function () {
+ var reporting = false;
+ {{#IF isInReportingMode}}
+ reporting = true;
+ {{/IF}}
+ var intervalMs = {{ retryValue.TotalMilliseconds }};
+
+ var box = document.getElementById('logs-scroll');
+ var panel = box ? box.closest('.logs-panel') : null;
+
+ function px(el, prop) {
+ return el ? (parseFloat(getComputedStyle(el)[prop]) || 0) : 0;
+ }
+
+ // Size the log viewport so the panel never runs past the bottom of the window
+ // (keeping the body's bottom padding as the margin). Recomputed on resize.
+ function fit() {
+ if (!box) {
+ return;
+ }
+ var topFromDoc = box.getBoundingClientRect().top + window.scrollY;
+ var below = px(panel, 'paddingBottom') + px(document.body, 'paddingBottom');
+ box.style.maxHeight = Math.max(window.innerHeight - topFromDoc - below, 120) + 'px';
+ }
+
+ function nearBottom(el) {
+ return el.scrollHeight - el.scrollTop - el.clientHeight < 24;
+ }
+
+ fit();
+ window.addEventListener('resize', fit);
+ window.addEventListener('load', fit);
+ if (box) {
+ box.scrollTop = box.scrollHeight; // start pinned to the newest entry
+ }
+
+ // In the terminal error state the page is static, so stop here.
+ if (reporting) {
+ return;
+ }
+
+ // Soft refresh: pull the page in the background and swap the log list + activity line in
+ // place, so polling never disturbs where the user has scrolled. Only follow to the bottom
+ // when the user is already there. A real reload happens only on the final transition.
+ function poll() {
+ fetch(window.location.href, { cache: 'no-store' }).then(function (resp) {
+ if (resp.ok) {
+ // The real server is now answering (HTTP 200) -> offer to continue to the app.
+ onServerReady();
+ return null;
+ }
+ return resp.text();
+ }).then(function (html) {
+ if (!html) {
+ return;
+ }
+ var doc = new DOMParser().parseFromString(html, 'text/html');
+
+ // Startup failed and the page switched to the error view -> reload to render it.
+ if (doc.querySelector('.status.is-error')) {
+ window.location.reload();
+ return;
+ }
+
+ var newStatus = doc.querySelector('.status-text');
+ var curStatus = document.querySelector('.status-text');
+ if (newStatus && curStatus) {
+ curStatus.innerHTML = newStatus.innerHTML;
+ }
+
+ if (box) {
+ var newList = doc.querySelector('#logs-scroll .action-list');
+ var curList = box.querySelector('.action-list');
+ if (newList && curList) {
+ var stick = nearBottom(box);
+ var prevTop = box.scrollTop;
+ curList.replaceWith(document.importNode(newList, true));
+ fit();
+ box.scrollTop = stick ? box.scrollHeight : prevTop;
+ }
+ }
+ }).catch(function () {
+ // Server is mid-transition (port rebinding); just try again on the next tick.
+ });
+ }
+
+ // The server finished starting. Stop polling and present a cancelable countdown so the
+ // user can either ride the redirect into the app or stay to review the startup output.
+ function onServerReady() {
+ clearInterval(pollTimer);
+
+ var status = document.querySelector('.status');
+ var statusText = document.querySelector('.status-text');
+ var spinner = document.querySelector('.mdl-spinner');
+ if (spinner) {
+ spinner.style.display = 'none';
+ }
+ if (status) {
+ status.classList.add('is-success');
+ }
+ if (statusText) {
+ statusText.textContent = 'Jellyfin started successfully.';
+ }
+ if (!status) {
+ window.location.reload();
+ return;
+ }
+
+ var bar = document.createElement('div');
+ bar.className = 'redirect-bar';
+ var countdownText = document.createElement('span');
+ countdownText.className = 'redirect-countdown';
+ var cancelButton = document.createElement('button');
+ cancelButton.type = 'button';
+ cancelButton.className = 'jf-button jf-button-secondary';
+ cancelButton.textContent = 'Cancel';
+ bar.appendChild(countdownText);
+ bar.appendChild(cancelButton);
+ status.insertAdjacentElement('afterend', bar);
+
+ var remaining = 5;
+ function renderCountdown() {
+ countdownText.textContent = 'Redirecting in ' + remaining + '…';
+ }
+ renderCountdown();
+ var countdown = setInterval(function () {
+ remaining -= 1;
+ if (remaining <= 0) {
+ clearInterval(countdown);
+ window.location.reload();
+ return;
+ }
+ renderCountdown();
+ }, 1000);
+
+ // Cancel stops both the redirect and the refreshing, and offers a manual continue.
+ cancelButton.addEventListener('click', function () {
+ clearInterval(countdown);
+ bar.innerHTML = '';
+ var continueButton = document.createElement('button');
+ continueButton.type = 'button';
+ continueButton.className = 'jf-button jf-button-primary';
+ continueButton.textContent = 'Continue to Jellyfin';
+ continueButton.addEventListener('click', function () {
+ window.location.reload();
+ });
+ bar.appendChild(continueButton);
+ });
+ }
+
+ var pollTimer = setInterval(poll, intervalMs);
+ })();
+ </script>
</body>
-{{^IF isInReportingMode}}
-<script>
- setTimeout(() => {
- window.location.reload();
- }, {{ retryValue.TotalMilliseconds }});
-</script>
-{{/IF}}
-
</html>
diff --git a/MediaBrowser.Controller/Channels/ChannelItemResult.cs b/MediaBrowser.Controller/Channels/ChannelItemResult.cs
index ca7721991d..9557c91964 100644
--- a/MediaBrowser.Controller/Channels/ChannelItemResult.cs
+++ b/MediaBrowser.Controller/Channels/ChannelItemResult.cs
@@ -1,19 +1,29 @@
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Channels
{
+ /// <summary>
+ /// The result of a channel item query.
+ /// </summary>
public class ChannelItemResult
{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ChannelItemResult"/> class.
+ /// </summary>
public ChannelItemResult()
{
Items = Array.Empty<ChannelItemInfo>();
}
+ /// <summary>
+ /// Gets or sets the items.
+ /// </summary>
public IReadOnlyList<ChannelItemInfo> Items { get; set; }
+ /// <summary>
+ /// Gets or sets the total record count.
+ /// </summary>
public int? TotalRecordCount { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelItemType.cs b/MediaBrowser.Controller/Channels/ChannelItemType.cs
index 3ce920e236..2608cb4c88 100644
--- a/MediaBrowser.Controller/Channels/ChannelItemType.cs
+++ b/MediaBrowser.Controller/Channels/ChannelItemType.cs
@@ -1,11 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ /// <summary>
+ /// The type of a channel item.
+ /// </summary>
public enum ChannelItemType
{
+ /// <summary>
+ /// The item is a media item.
+ /// </summary>
Media = 0,
+ /// <summary>
+ /// The item is a folder.
+ /// </summary>
Folder = 1
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs
index ebbe13763b..c6530814b9 100644
--- a/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs
+++ b/MediaBrowser.Controller/Channels/ChannelLatestMediaSearch.cs
@@ -1,11 +1,15 @@
#nullable disable
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ /// <summary>
+ /// The request for a latest media search in a channel.
+ /// </summary>
public class ChannelLatestMediaSearch
{
+ /// <summary>
+ /// Gets or sets the user id.
+ /// </summary>
public string UserId { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs
index f77d81c166..a5a1ba5bf6 100644
--- a/MediaBrowser.Controller/Channels/ChannelParentalRating.cs
+++ b/MediaBrowser.Controller/Channels/ChannelParentalRating.cs
@@ -1,17 +1,33 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ /// <summary>
+ /// The parental rating of a channel.
+ /// </summary>
public enum ChannelParentalRating
{
+ /// <summary>
+ /// Suitable for a general audience.
+ /// </summary>
GeneralAudience = 0,
+ /// <summary>
+ /// Parental guidance suggested (US PG).
+ /// </summary>
UsPG = 1,
+ /// <summary>
+ /// Parents strongly cautioned (US PG-13).
+ /// </summary>
UsPG13 = 2,
+ /// <summary>
+ /// Restricted (US R).
+ /// </summary>
UsR = 3,
+ /// <summary>
+ /// Suitable for adults only.
+ /// </summary>
Adult = 4
}
}
diff --git a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs
index 990b025bcb..d172b98b25 100644
--- a/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs
+++ b/MediaBrowser.Controller/Channels/ChannelSearchInfo.cs
@@ -1,13 +1,20 @@
#nullable disable
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ /// <summary>
+ /// The request for a search in a channel.
+ /// </summary>
public class ChannelSearchInfo
{
+ /// <summary>
+ /// Gets or sets the search term.
+ /// </summary>
public string SearchTerm { get; set; }
+ /// <summary>
+ /// Gets or sets the user id.
+ /// </summary>
public string UserId { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Channels/IHasCacheKey.cs b/MediaBrowser.Controller/Channels/IHasCacheKey.cs
index 7d5207c34a..4cdda38bd9 100644
--- a/MediaBrowser.Controller/Channels/IHasCacheKey.cs
+++ b/MediaBrowser.Controller/Channels/IHasCacheKey.cs
@@ -1,14 +1,15 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Channels
{
+ /// <summary>
+ /// Interface for channels that provide a cache key.
+ /// </summary>
public interface IHasCacheKey
{
/// <summary>
/// Gets the cache key.
/// </summary>
/// <param name="userId">The user identifier.</param>
- /// <returns>System.String.</returns>
+ /// <returns>The cache key.</returns>
string? GetCacheKey(string? userId);
}
}
diff --git a/MediaBrowser.Controller/Channels/ISupportsDelete.cs b/MediaBrowser.Controller/Channels/ISupportsDelete.cs
index 0110bfa7a3..194654ca9e 100644
--- a/MediaBrowser.Controller/Channels/ISupportsDelete.cs
+++ b/MediaBrowser.Controller/Channels/ISupportsDelete.cs
@@ -1,15 +1,27 @@
-#pragma warning disable CS1591
-
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Channels
{
+ /// <summary>
+ /// Interface for channels that support deleting items.
+ /// </summary>
public interface ISupportsDelete
{
+ /// <summary>
+ /// Gets a value indicating whether the item can be deleted.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <returns><c>true</c> if the item can be deleted, <c>false</c> otherwise.</returns>
bool CanDelete(BaseItem item);
+ /// <summary>
+ /// Deletes the item with the provided id.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A task representing the deletion of the item.</returns>
Task DeleteItem(string id, CancellationToken cancellationToken);
}
}
diff --git a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs
index 1935ec0f5f..82ca45d3ad 100644
--- a/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs
+++ b/MediaBrowser.Controller/Channels/ISupportsLatestMedia.cs
@@ -1,11 +1,12 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.Channels
{
+ /// <summary>
+ /// Interface for channels that support retrieving the latest media.
+ /// </summary>
public interface ISupportsLatestMedia
{
/// <summary>
diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
index 14dc64dabd..36f0d2195c 100644
--- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
+++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs
@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Threading.Tasks;
+using Jellyfin.Extensions;
namespace MediaBrowser.Controller.ClientEvent
{
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
/// <inheritdoc />
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
{
- var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
+ var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
+ var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
+ var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
+ if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
+ {
+ throw new ArgumentException("Path resolved to filename not in log directory");
+ }
+
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await using (fileStream.ConfigureAwait(false))
{
diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs
index d319feb6b2..052626355f 100644
--- a/MediaBrowser.Controller/Dto/DtoOptions.cs
+++ b/MediaBrowser.Controller/Dto/DtoOptions.cs
@@ -82,13 +82,6 @@ namespace MediaBrowser.Controller.Dto
public bool AddCurrentProgram { get; set; }
/// <summary>
- /// Gets or sets a value indicating whether an episode's portrait poster (its season's primary
- /// image, falling back to the series') should replace the episode's own (16:9) primary image.
- /// Used by views that render episodes as poster cards, e.g. "Latest".
- /// </summary>
- public bool PreferEpisodeParentPoster { get; set; }
-
- /// <summary>
/// Gets a value indicating whether the specified field is populated.
/// </summary>
/// <param name="field">The field to check.</param>
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 21304768bd..49a4ed4bf6 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -87,6 +87,8 @@ namespace MediaBrowser.Controller.Entities
Model.Entities.ExtraType.Short
};
+ private static readonly char[] VersionDelimiters = ['-', '_', '.'];
+
private string _sortName;
private string _forcedSortName;
@@ -1099,8 +1101,9 @@ namespace MediaBrowser.Controller.Entities
}
}
- var list = GetAllItemsForMediaSources();
- var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList();
+ var list = GetAllItemsForMediaSources().ToList();
+ var commonPrefix = GetCommonNamePrefix(list);
+ var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList();
if (IsActiveRecording())
{
@@ -1110,17 +1113,15 @@ namespace MediaBrowser.Controller.Entities
}
}
- return result.OrderBy(i =>
- {
- if (i.VideoType == VideoType.VideoFile)
- {
- return 0;
- }
+ // The source belonging to the item being queried sorts first so it is the default the client plays.
+ var selfId = Id.ToString("N", CultureInfo.InvariantCulture);
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i => i, new MediaSourceWidthComparator())
- .ToArray();
+ return result
+ .OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1)
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i => i, new MediaSourceWidthComparator())
+ .ToArray();
}
protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
@@ -1128,7 +1129,7 @@ namespace MediaBrowser.Controller.Entities
return Enumerable.Empty<(BaseItem, MediaSourceType)>();
}
- private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type)
+ private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null)
{
ArgumentNullException.ThrowIfNull(item);
@@ -1141,7 +1142,7 @@ namespace MediaBrowser.Controller.Entities
Protocol = protocol ?? MediaProtocol.File,
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
- Name = GetMediaSourceName(item),
+ Name = GetMediaSourceName(item, commonPrefix),
Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks,
Container = item.Container,
@@ -1220,7 +1221,7 @@ namespace MediaBrowser.Controller.Entities
return info;
}
- internal string GetMediaSourceName(BaseItem item)
+ internal string GetMediaSourceName(BaseItem item, string commonPrefix = null)
{
var terms = new List<string>();
@@ -1228,12 +1229,31 @@ namespace MediaBrowser.Controller.Entities
if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
{
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
- if (HasLocalAlternateVersions)
+
+ // Prefer the suffix that differs from the other versions: strip the prefix shared by
+ // all sibling files. This works regardless of folder layout, so it also labels episode
+ // versions that share a season folder (e.g. "Greyscale" instead of the full
+ // "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter
+ // boundary (see GetCommonVersionPrefix).
+ if (!string.IsNullOrEmpty(commonPrefix)
+ && displayName.Length > commonPrefix.Length
+ && displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]);
+ if (!name.IsWhiteSpace())
+ {
+ terms.Add(name.ToString());
+ }
+ }
+
+ // Fall back to the containing folder name (the common layout for movie versions, and
+ // the path taken when no common prefix could be derived).
+ if (terms.Count == 0 && HasLocalAlternateVersions)
{
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
{
- var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
+ var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
@@ -1290,6 +1310,98 @@ namespace MediaBrowser.Controller.Entities
return string.Join('/', terms);
}
+ /// <summary>
+ /// Derives the prefix shared by the supplied media source items' file names, used to strip the
+ /// common part and surface a short version label per source. Returns null when there are fewer
+ /// than two file-based sources, since there is nothing to differentiate.
+ /// </summary>
+ /// <param name="items">The media source items.</param>
+ /// <returns>The shared prefix, or null when no useful prefix exists.</returns>
+ private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items)
+ {
+ var fileNames = new List<string>();
+ foreach (var (item, _) in items)
+ {
+ if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path))
+ {
+ fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path));
+ }
+ }
+
+ if (fileNames.Count < 2)
+ {
+ return null;
+ }
+
+ var prefix = GetCommonVersionPrefix(fileNames);
+ return string.IsNullOrEmpty(prefix) ? null : prefix;
+ }
+
+ /// <summary>
+ /// Computes the case-insensitive longest common prefix of the supplied version file names,
+ /// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact:
+ /// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and
+ /// "Greyish") while still trimming the common part when every version is suffixed (e.g.
+ /// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a
+ /// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is
+ /// kept in the label, falling back to a space only when no structural delimiter is shared. The
+ /// separators mirror the version delimiters recognised by the naming layer (Emby.Naming
+ /// VideoFlagDelimiters).
+ /// </summary>
+ /// <param name="fileNames">The version file names without extension; must contain at least one entry.</param>
+ /// <returns>The shared prefix retreated to a separator boundary, or an empty string when none is shared.</returns>
+ internal static string GetCommonVersionPrefix(IReadOnlyList<string> fileNames)
+ {
+ var prefix = fileNames[0];
+ for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++)
+ {
+ var name = fileNames[i];
+ var length = Math.Min(prefix.Length, name.Length);
+ var common = 0;
+ while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common]))
+ {
+ common++;
+ }
+
+ prefix = prefix[..common];
+ }
+
+ // If the common prefix is itself a whole file name then one version is unlabelled (the
+ // base name); the boundary already sits at the end of that name, so don't retreat into it.
+ var prefixIsWholeName = false;
+ for (var i = 0; i < fileNames.Count; i++)
+ {
+ if (fileNames[i].Length == prefix.Length)
+ {
+ prefixIsWholeName = true;
+ break;
+ }
+ }
+
+ if (!prefixIsWholeName)
+ {
+ // Retreat to the last structural delimiter ('-', '_', '.').
+ var cut = prefix.Length;
+ while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0)
+ {
+ cut--;
+ }
+
+ if (cut == 0)
+ {
+ cut = prefix.Length;
+ while (cut > 0 && prefix[cut - 1] != ' ')
+ {
+ cut--;
+ }
+ }
+
+ prefix = prefix[..cut];
+ }
+
+ return prefix;
+ }
+
public Task RefreshMetadata(CancellationToken cancellationToken)
{
return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);
@@ -2011,12 +2123,23 @@ namespace MediaBrowser.Controller.Entities
// I think it is okay to do this here.
// if this is only called when a user is manually forcing something to un-played
// then it probably is what we want to do...
+ ResetPlayedState(data);
+
+ UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+
+ /// <summary>
+ /// Clears the played state on the supplied user data.
+ /// </summary>
+ /// <param name="data">The user data to reset.</param>
+ protected static void ResetPlayedState(UserItemData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
data.PlayCount = 0;
data.PlaybackPositionTicks = 0;
data.LastPlayedDate = null;
data.Played = false;
-
- UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
}
/// <summary>
@@ -2732,6 +2855,15 @@ namespace MediaBrowser.Controller.Entities
}
/// <summary>
+ /// Gets the ids of the items whose owned extras belong to this item.
+ /// </summary>
+ /// <returns>An array containing the owner ids.</returns>
+ protected virtual Guid[] GetExtraOwnerIds()
+ {
+ return [Id];
+ }
+
+ /// <summary>
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
/// </summary>
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
@@ -2740,7 +2872,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
@@ -2755,7 +2887,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
- OwnerIds = [Id],
+ OwnerIds = GetExtraOwnerIds(),
ExtraTypes = extraTypes.ToArray(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
diff --git a/MediaBrowser.Controller/Entities/Book.cs b/MediaBrowser.Controller/Entities/Book.cs
index 5187669373..8559681bdc 100644
--- a/MediaBrowser.Controller/Entities/Book.cs
+++ b/MediaBrowser.Controller/Entities/Book.cs
@@ -13,11 +13,6 @@ namespace MediaBrowser.Controller.Entities
[Common.RequiresSourceSerialisation]
public class Book : BaseItem, IHasLookupInfo<BookInfo>, IHasSeries
{
- public Book()
- {
- this.RunTimeTicks = TimeSpan.TicksPerSecond;
- }
-
[JsonIgnore]
public override MediaType MediaType => MediaType.Book;
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index 25cbcedc5f..b1f7f29bad 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -384,6 +384,7 @@ namespace MediaBrowser.Controller.Entities
cancellationToken.ThrowIfCancellationRequested();
var validChildren = new List<BaseItem>();
+ var accessibleChildren = new List<BaseItem>();
var validChildrenNeedGeneration = false;
if (IsFileProtocol)
@@ -438,12 +439,19 @@ namespace MediaBrowser.Controller.Entities
{
if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot))
{
+ // Preserve inaccessible items so they aren't treated as removed.
+ if (currentChildren.TryGetValue(child.Id, out var childrenToKeep))
+ {
+ validChildren.Add(childrenToKeep);
+ }
+
continue;
}
if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild))
{
validChildren.Add(currentChild);
+ accessibleChildren.Add(currentChild);
if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
{
@@ -480,11 +488,12 @@ namespace MediaBrowser.Controller.Entities
child.SetParent(this);
newItems.Add(child);
validChildren.Add(child);
+ accessibleChildren.Add(child);
}
// 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;
+
// If it's an AggregateFolder, don't remove
// Collect replaced primaries for deferred deletion (after CreateItems)
var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>();
@@ -497,7 +506,7 @@ namespace MediaBrowser.Controller.Entities
.Where(p => !string.IsNullOrEmpty(p))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
- if (shouldRemove && itemsRemoved.Count > 0)
+ if (itemsRemoved.Count > 0)
{
foreach (var item in itemsRemoved)
{
@@ -703,7 +712,7 @@ namespace MediaBrowser.Controller.Entities
validChildrenNeedGeneration = false;
}
- await ValidateSubFolders(validChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
+ await ValidateSubFolders(accessibleChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
}
if (refreshChildMetadata)
@@ -742,7 +751,7 @@ namespace MediaBrowser.Controller.Entities
validChildren = Children.ToList();
}
- await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
+ await RefreshMetadataRecursive(accessibleChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
}
}
}
diff --git a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
index f47d2162f7..0cdc8bce03 100644
--- a/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
+++ b/MediaBrowser.Controller/Entities/IHasSpecialFeatures.cs
@@ -1,12 +1,13 @@
#nullable disable
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// Interface for items that have special features.
+ /// </summary>
public interface IHasSpecialFeatures
{
/// <summary>
diff --git a/MediaBrowser.Controller/Entities/IHasStartDate.cs b/MediaBrowser.Controller/Entities/IHasStartDate.cs
index dab15eb018..47df09d1ce 100644
--- a/MediaBrowser.Controller/Entities/IHasStartDate.cs
+++ b/MediaBrowser.Controller/Entities/IHasStartDate.cs
@@ -1,11 +1,15 @@
-#pragma warning disable CS1591
-
using System;
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// Interface for items that have a start date.
+ /// </summary>
public interface IHasStartDate
{
+ /// <summary>
+ /// Gets or sets the start date.
+ /// </summary>
DateTime StartDate { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Entities/IItemByName.cs b/MediaBrowser.Controller/Entities/IItemByName.cs
index 4928bda7a2..756dbecb98 100644
--- a/MediaBrowser.Controller/Entities/IItemByName.cs
+++ b/MediaBrowser.Controller/Entities/IItemByName.cs
@@ -1,19 +1,28 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
namespace MediaBrowser.Controller.Entities
{
/// <summary>
- /// Marker interface.
+ /// Marker interface for items that represent a name, like a genre or a studio.
/// </summary>
public interface IItemByName
{
+ /// <summary>
+ /// Gets the items tagged with this name.
+ /// </summary>
+ /// <param name="query">The query.</param>
+ /// <returns>The tagged items.</returns>
IReadOnlyList<BaseItem> GetTaggedItems(InternalItemsQuery query);
}
+ /// <summary>
+ /// Interface for by-name items that can also be accessed as a regular library item.
+ /// </summary>
public interface IHasDualAccess : IItemByName
{
+ /// <summary>
+ /// Gets a value indicating whether the item is accessed by name.
+ /// </summary>
bool IsAccessedByName { get; }
}
}
diff --git a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
index cdda8ea399..0f8904df5c 100644
--- a/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
+++ b/MediaBrowser.Controller/Entities/ISupportsPlaceHolders.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// Interface for items that can be placeholders.
+ /// </summary>
public interface ISupportsPlaceHolders
{
/// <summary>
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 422c40ce5d..3b1f6a961f 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -72,6 +72,102 @@ namespace MediaBrowser.Controller.Entities
}
}
+ /// <summary>
+ /// Gets a value indicating whether the query carries any criteria that narrows the
+ /// result set, as opposed to user context, pagination, sorting or DTO options.
+ /// </summary>
+ public bool HasFilters =>
+ IncludeItemTypes.Length > 0
+ || ExcludeItemTypes.Length > 0
+ || Genres.Count > 0
+ || GenreIds.Count > 0
+ || Years.Length > 0
+ || Tags.Length > 0
+ || ExcludeTags.Length > 0
+ || OfficialRatings.Length > 0
+ || StudioIds.Length > 0
+ || ArtistIds.Length > 0
+ || AlbumArtistIds.Length > 0
+ || ContributingArtistIds.Length > 0
+ || ExcludeArtistIds.Length > 0
+ || AlbumIds.Length > 0
+ || PersonIds.Length > 0
+ || PersonTypes.Length > 0
+ || MediaTypes.Length > 0
+ || VideoTypes.Length > 0
+ || ImageTypes.Length > 0
+ || SeriesStatuses.Length > 0
+ || ItemIds.Length > 0
+ || ExcludeItemIds.Length > 0
+ || AudioLanguages.Count > 0
+ || SubtitleLanguages.Count > 0
+ || LinkedChildAncestorIds.Length > 0
+ || AncestorIds.Length > 0
+ || IsFavorite.HasValue
+ || IsFavoriteOrLiked.HasValue
+ || IsLiked.HasValue
+ || IsPlayed.HasValue
+ || IsResumable.HasValue
+ || IsFolder.HasValue
+ || IsMissing.HasValue
+ || IsUnaired.HasValue
+ || IsSpecialSeason.HasValue
+ || Is3D.HasValue
+ || IsHD.HasValue
+ || Is4K.HasValue
+ || IsLocked.HasValue
+ || IsPlaceHolder.HasValue
+ || IsMovie.HasValue
+ || IsSports.HasValue
+ || IsKids.HasValue
+ || IsNews.HasValue
+ || IsSeries.HasValue
+ || IsAiring.HasValue
+ || IsVirtualItem.HasValue
+ || HasImdbId.HasValue
+ || HasTmdbId.HasValue
+ || HasTvdbId.HasValue
+ || HasOverview.HasValue
+ || HasOfficialRating.HasValue
+ || HasParentalRating.HasValue
+ || HasThemeSong.HasValue
+ || HasThemeVideo.HasValue
+ || HasSubtitles.HasValue
+ || HasSpecialFeature.HasValue
+ || HasTrailer.HasValue
+ || HasChapterImages.HasValue
+ || MinCriticRating.HasValue
+ || MinCommunityRating.HasValue
+ || MinParentalRating is not null
+ || MinIndexNumber.HasValue
+ || MinParentAndIndexNumber.HasValue
+ || IndexNumber.HasValue
+ || ParentIndexNumber.HasValue
+ || AiredDuringSeason.HasValue
+ || MinWidth.HasValue
+ || MinHeight.HasValue
+ || MaxWidth.HasValue
+ || MaxHeight.HasValue
+ || MinPremiereDate.HasValue
+ || MaxPremiereDate.HasValue
+ || MinStartDate.HasValue
+ || MaxStartDate.HasValue
+ || MinEndDate.HasValue
+ || MaxEndDate.HasValue
+ || MinDateCreated.HasValue
+ || MinDateLastSaved.HasValue
+ || MinDateLastSavedForUser.HasValue
+ || AdjacentTo.HasValue
+ || !string.IsNullOrEmpty(NameStartsWith)
+ || !string.IsNullOrEmpty(NameStartsWithOrGreater)
+ || !string.IsNullOrEmpty(NameLessThan)
+ || !string.IsNullOrEmpty(NameContains)
+ || !string.IsNullOrEmpty(MinSortName)
+ || !string.IsNullOrEmpty(Name)
+ || !string.IsNullOrEmpty(Person)
+ || !string.IsNullOrEmpty(SearchTerm)
+ || !string.IsNullOrEmpty(Path);
+
public bool Recursive { get; set; }
public int? StartIndex { get; set; }
diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs
index 5cc4d322f7..14325d971a 100644
--- a/MediaBrowser.Controller/Entities/Person.cs
+++ b/MediaBrowser.Controller/Entities/Person.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging;
@@ -75,6 +76,16 @@ namespace MediaBrowser.Controller.Entities
return false;
}
+ /// <inheritdoc />
+ /// <remarks>
+ /// People don't carry the tags of the media they appear in, so the allowed tags check
+ /// is skipped for them; otherwise no person would be visible to users with allowed tags configured.
+ /// </remarks>
+ public override bool IsVisible(User user, bool skipAllowedTagsCheck = false)
+ {
+ return base.IsVisible(user, true);
+ }
+
public override bool IsSaveLocalMetadataEnabled()
{
return true;
diff --git a/MediaBrowser.Controller/Entities/SourceType.cs b/MediaBrowser.Controller/Entities/SourceType.cs
index be19e1bdae..97aa22dc04 100644
--- a/MediaBrowser.Controller/Entities/SourceType.cs
+++ b/MediaBrowser.Controller/Entities/SourceType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Entities
{
+ /// <summary>
+ /// The source of an item.
+ /// </summary>
public enum SourceType
{
+ /// <summary>
+ /// The item comes from a library.
+ /// </summary>
Library = 0,
+
+ /// <summary>
+ /// The item comes from a channel.
+ /// </summary>
Channel = 1,
+
+ /// <summary>
+ /// The item comes from live TV.
+ /// </summary>
LiveTV = 2
}
}
diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs
index deed3631b8..d5be997b84 100644
--- a/MediaBrowser.Controller/Entities/UserRootFolder.cs
+++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs
@@ -69,8 +69,14 @@ namespace MediaBrowser.Controller.Entities
protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
{
- if (query.Recursive)
+ // The user root holds no items of its own - a plain listing returns the user's
+ // views. But a request carrying any filter is a search across the libraries, so
+ // resolve it through the recursive query path even when Recursive wasn't set;
+ // otherwise the filters would be silently dropped. Recursive is set so the
+ // downstream query (ancestor/top-parent scoping) treats it as a recursive search.
+ if (query.Recursive || query.HasFilters)
{
+ query.Recursive = true;
return QueryRecursive(query);
}
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index cb05056601..c57ed2faf8 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities
case CollectionType.folders:
return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query);
+ case CollectionType.books:
+ return GetBooks(queryParent, user, query);
+
case CollectionType.tvshows:
return GetTvView(queryParent, user, query);
@@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities
return _libraryManager.GetItemsResult(query);
}
+ private QueryResult<BaseItem> GetBooks(Folder parent, User user, InternalItemsQuery query)
+ {
+ query.Recursive = true;
+ query.Parent = parent;
+ query.SetUser(user);
+
+ query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
+
+ return _libraryManager.GetItemsResult(query);
+ }
+
private QueryResult<BaseItem> GetMovieMovies(Folder parent, User user, InternalItemsQuery query)
{
query.Recursive = true;
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index e7a5672ebd..0606fe1870 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -34,11 +34,11 @@ namespace MediaBrowser.Controller.Entities
{
public Video()
{
- AdditionalParts = Array.Empty<string>();
- LocalAlternateVersions = Array.Empty<string>();
- SubtitleFiles = Array.Empty<string>();
- AudioFiles = Array.Empty<string>();
- LinkedAlternateVersions = Array.Empty<LinkedChild>();
+ AdditionalParts = [];
+ LocalAlternateVersions = [];
+ SubtitleFiles = [];
+ AudioFiles = [];
+ LinkedAlternateVersions = [];
}
[JsonIgnore]
@@ -254,7 +254,7 @@ namespace MediaBrowser.Controller.Entities
private int GetMediaSourceCount(HashSet<Guid> callstack = null)
{
- callstack ??= new();
+ callstack ??= [];
if (PrimaryVersionId.HasValue)
{
var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
@@ -335,6 +335,102 @@ namespace MediaBrowser.Controller.Entities
PresentationUniqueKey = CreatePresentationUniqueKey();
}
+ /// <summary>
+ /// Marks the played status of this video and propagates it to its alternate versions.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="datePlayed">The date played.</param>
+ /// <param name="resetPosition">if set to <c>true</c> [reset position].</param>
+ public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
+ {
+ base.MarkPlayed(user, datePlayed, resetPosition);
+ PropagatePlayedState(user, true, resetPosition);
+ }
+
+ /// <summary>
+ /// Marks this video unplayed and propagates the change to its alternate versions.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ public override void MarkUnplayed(User user)
+ {
+ base.MarkUnplayed(user);
+
+ // MarkUnplayed always clears the position on this video, so reset the versions too.
+ PropagatePlayedState(user, false, true);
+ }
+
+ /// <summary>
+ /// Propagates the played status to every alternate version of this video.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="played">The played status to apply to the alternate versions.</param>
+ /// <param name="resetPosition">When marking played, controls whether each version's resume point
+ /// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed,
+ /// which always fully resets every version.</param>
+ public void PropagatePlayedState(User user, bool played, bool resetPosition = true)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions)
+ {
+ return;
+ }
+
+ foreach (var (item, _) in GetAllItemsForMediaSources())
+ {
+ if (item.Id.Equals(Id) || item is not Video)
+ {
+ continue;
+ }
+
+ if (played)
+ {
+ var dto = new UpdateUserItemDataDto { Played = true };
+ if (resetPosition)
+ {
+ dto.PlaybackPositionTicks = 0;
+ }
+
+ // SaveUserData only writes the fields set on the DTO, so play count and other state are preserved.
+ UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed);
+ }
+ else
+ {
+ var data = UserDataManager.GetUserData(user, item);
+ if (data is null)
+ {
+ continue;
+ }
+
+ ResetPlayedState(data);
+ UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Gets this video together with all of its alternate versions (local and linked and, when this
+ /// is itself an alternate, the primary and the primary's other versions), deduplicated.
+ /// </summary>
+ /// <returns>This video and every alternate version of it.</returns>
+ public IReadOnlyList<Video> GetAllVersions()
+ {
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item)
+ .OfType<Video>()
+ .ToList();
+ }
+
+ /// <summary>
+ /// Gets the alternate version of this video that matches the supplied item id.
+ /// </summary>
+ /// <param name="itemId">The version item id (the playback media source id).</param>
+ /// <returns>The matching version, or <c>null</c> when the id is not a version of this video.</returns>
+ public Video GetAlternateVersion(Guid itemId)
+ {
+ return GetAllVersions().FirstOrDefault(i => i.Id.Equals(itemId));
+ }
+
public override string CreatePresentationUniqueKey()
{
if (PrimaryVersionId.HasValue)
@@ -396,8 +492,8 @@ namespace MediaBrowser.Controller.Entities
public IOrderedEnumerable<Video> GetAdditionalParts(User user = null)
{
return GetAdditionalPartIds()
- .Select(i => LibraryManager.GetItemById<Video>(i, user))
- .Where(i => i is not null)
+ .Select(i => LibraryManager.GetItemById<Video>(i))
+ .Where(i => i is not null && (user is null || i.IsVisible(user)))
.OrderBy(i => i.SortName);
}
@@ -642,39 +738,58 @@ namespace MediaBrowser.Controller.Entities
}).FirstOrDefault();
}
- protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
+ /// <summary>
+ /// Gets the ids of the items whose owned extras belong to this item.
+ /// Extras are linked to a single version but need tp be surfaced for all versions.
+ /// </summary>
+ /// <returns>An array containing the owner ids.</returns>
+ protected override Guid[] GetExtraOwnerIds()
{
- var list = new List<(BaseItem, MediaSourceType)>
- {
- (this, MediaSourceType.Default)
- };
-
- list.AddRange(
- LibraryManager.GetLinkedAlternateVersions(this)
- .Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
+ return GetAllItemsForMediaSources()
+ .Select(i => i.Item.Id)
+ .Distinct()
+ .ToArray();
+ }
- if (PrimaryVersionId.HasValue)
- {
- if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary)
- {
- var existingIds = list.Select(i => i.Item1.Id).ToList();
- list.Add((primary, MediaSourceType.Grouping));
- list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
- }
- }
+ protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
+ {
+ var primary = PrimaryVersionId.HasValue
+ ? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
+ : null;
+
+ var primaryLinked = primary is null
+ ? []
+ : LibraryManager.GetLinkedAlternateVersions(primary).ToList();
+
+ // Grouping marks user-merged (splittable) sources. The primary is only such a source when
+ // this video is linked onto it; for local (file-based) alternates the primary is just
+ // another default source.
+ var primaryType = primaryLinked.Any(i => i.Id.Equals(Id))
+ ? MediaSourceType.Grouping
+ : MediaSourceType.Default;
+
+ // This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates.
+ var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) }
+ .Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
+ .Concat(primary is null
+ ? []
+ : primaryLinked.Select(i => ((BaseItem)i, MediaSourceType.Grouping)).Prepend(((BaseItem)primary, primaryType)))
+ .ToList();
- var localAlternates = list
- .SelectMany(i =>
- {
- return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>();
- })
+ // The local (file-based) alternate versions of every grouped item.
+ var localAlternates = grouped
+ .Select(i => i.Item1)
+ .OfType<Video>()
+ .SelectMany(LibraryManager.GetLocalAlternateVersionIds)
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)
- .ToList();
-
- list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default)));
+ .Select(i => (i, MediaSourceType.Default));
- return list;
+ // Deduplicate
+ return grouped
+ .Concat(localAlternates)
+ .DistinctBy(i => i.Item1.Id)
+ .ToList();
}
}
}
diff --git a/MediaBrowser.Controller/Library/IUserDataManager.cs b/MediaBrowser.Controller/Library/IUserDataManager.cs
index 798812bf1f..2ee8845346 100644
--- a/MediaBrowser.Controller/Library/IUserDataManager.cs
+++ b/MediaBrowser.Controller/Library/IUserDataManager.cs
@@ -63,6 +63,24 @@ namespace MediaBrowser.Controller.Library
Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user);
/// <summary>
+ /// Gets the user data that should drive resume for a multi-version item: the data of the most
+ /// recently played alternate version (including the item itself) that has a resume point.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="item">The item.</param>
+ /// <returns>The resume version's data, or <c>null</c> when the item has no versions or none has a resume point.</returns>
+ VersionResumeData? GetResumeUserData(User user, BaseItem item);
+
+ /// <summary>
+ /// Gets the resume-driving user data for multiple items in a single batch operation.
+ /// See <see cref="GetResumeUserData(User, BaseItem)"/>.
+ /// </summary>
+ /// <param name="items">The items to get resume data for.</param>
+ /// <param name="user">The user.</param>
+ /// <returns>A dictionary mapping item ids to their resume version's data; items without one are omitted.</returns>
+ IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user);
+
+ /// <summary>
/// Gets the user data dto.
/// </summary>
/// <param name="item">Item to use.</param>
@@ -80,5 +98,13 @@ namespace MediaBrowser.Controller.Library
/// <param name="reportedPositionTicks">New playstate.</param>
/// <returns>True if playstate was updated.</returns>
bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks);
+
+ /// <summary>
+ /// Clears any stored audio and subtitle stream selections for the given user/item pair.
+ /// Used when the user has opted out of remembering selections.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="item">The item.</param>
+ void ResetPlaybackStreamSelections(User user, BaseItem item);
}
}
diff --git a/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs
new file mode 100644
index 0000000000..1766c50141
--- /dev/null
+++ b/MediaBrowser.Controller/Library/VersionPlaybackSelector.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.Library
+{
+ /// <summary>
+ /// Single definition of "which alternate version was most recently played" shared by the resume tile
+ /// (<see cref="IUserDataManager.GetResumeUserData"/>), the media-source default ordering and Next Up.
+ /// Each call site declares its own eligibility rule so the intentional differences (resumable-only vs.
+ /// resumable-or-completed) are visible in one place instead of being re-implemented divergently.
+ /// The SQL resume query keeps its own translation of the same rule.
+ /// </summary>
+ public static class VersionPlaybackSelector
+ {
+ /// <summary>
+ /// Selects the entry whose user data has the greatest <see cref="UserItemData.LastPlayedDate"/>,
+ /// considering only entries that satisfy <paramref name="isEligible"/>. On an exact tie the first
+ /// encountered entry wins.
+ /// </summary>
+ /// <typeparam name="T">The candidate type (e.g. a version item or a media source).</typeparam>
+ /// <param name="items">The candidates to choose from.</param>
+ /// <param name="dataSelector">Resolves the user data for a candidate, or <c>null</c> when it has none.</param>
+ /// <param name="isEligible">Whether a candidate's user data makes it a valid winner.</param>
+ /// <returns>The most recently played eligible candidate, or <c>default</c> when none qualify.</returns>
+ public static T? SelectMostRecentlyPlayed<T>(
+ IEnumerable<T> items,
+ Func<T, UserItemData?> dataSelector,
+ Func<UserItemData, bool> isEligible)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+ ArgumentNullException.ThrowIfNull(dataSelector);
+ ArgumentNullException.ThrowIfNull(isEligible);
+
+ T? winner = default;
+ var winnerDate = DateTime.MinValue;
+ var hasWinner = false;
+
+ foreach (var item in items)
+ {
+ var data = dataSelector(item);
+ if (data is null || !isEligible(data))
+ {
+ continue;
+ }
+
+ var date = data.LastPlayedDate ?? DateTime.MinValue;
+ if (!hasWinner || date > winnerDate)
+ {
+ winner = item;
+ winnerDate = date;
+ hasWinner = true;
+ }
+ }
+
+ return winner;
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/Library/VersionResumeData.cs b/MediaBrowser.Controller/Library/VersionResumeData.cs
new file mode 100644
index 0000000000..772e2bf3a7
--- /dev/null
+++ b/MediaBrowser.Controller/Library/VersionResumeData.cs
@@ -0,0 +1,41 @@
+using System;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Dto;
+
+namespace MediaBrowser.Controller.Library
+{
+ /// <summary>
+ /// The user data of the most recently played alternate version that should drive the completion state of a multi-version item.
+ /// </summary>
+ /// <param name="VersionId">The id of the version that owns <paramref name="UserData"/>.</param>
+ /// <param name="UserData">The resume version's user data.</param>
+ public record VersionResumeData(Guid VersionId, UserItemData UserData)
+ {
+ /// <summary>
+ /// Merges the most recently played version's completion state into the supplied user data dto.
+ /// Completion (played) propagates to the primary. An in-progress resume position stays on the version
+ /// that owns it, which is surfaced directly (e.g. in resume queries) so that playback always targets
+ /// the correct version rather than resuming the primary at another version's offset. When the movie was
+ /// finished on a different version, the primary's own stale resume position is cleared so it does not
+ /// render as "watched and resumable" at the same time.
+ /// </summary>
+ /// <param name="dto">The user data dto to update.</param>
+ public void ApplyTo(UserItemDataDto dto)
+ {
+ dto.Played = dto.Played || UserData.Played;
+
+ if ((UserData.LastPlayedDate ?? DateTime.MinValue) > (dto.LastPlayedDate ?? DateTime.MinValue))
+ {
+ dto.LastPlayedDate = UserData.LastPlayedDate;
+ }
+
+ // A different version was finished (played, no resume position of its own) and is the most
+ // recently played: the whole movie is watched.
+ if (!VersionId.Equals(dto.ItemId) && UserData.Played && UserData.PlaybackPositionTicks <= 0)
+ {
+ dto.PlaybackPositionTicks = 0;
+ dto.PlayedPercentage = null;
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 320e65231c..1b0bbe9ea0 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -25,6 +25,7 @@ using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
using Microsoft.Extensions.Configuration;
using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
@@ -2611,56 +2612,66 @@ namespace MediaBrowser.Controller.MediaEncoding
}
public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs)
+ => CanStreamCopyAudio(state, audioStream, supportedAudioCodecs, out _);
+
+ /// <summary>
+ /// Determines whether the given audio stream can be stream-copied and, regardless of the outcome,
+ /// reports the codec/parameter incompatibilities that would force a re-encode via <paramref name="failureReasons"/>.
+ /// </summary>
+ /// <param name="state">The encoding job state.</param>
+ /// <param name="audioStream">The source audio stream.</param>
+ /// <param name="supportedAudioCodecs">The audio codecs the target supports.</param>
+ /// <param name="failureReasons">The codec/parameter incompatibilities preventing a copy, or <c>0</c> if the stream is copy-compatible.</param>
+ /// <returns><c>true</c> if the audio stream can be stream-copied; otherwise, <c>false</c>.</returns>
+ public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs, out TranscodeReason failureReasons)
{
var request = state.BaseRequest;
- if (!request.AllowAudioStreamCopy)
- {
- return false;
- }
+ // Policy-independent compatibility check, so the reasons are reported even when a policy gate is what ultimately prevents the copy.
+ failureReasons = GetAudioStreamCopyFailureReasons(state, audioStream, supportedAudioCodecs);
+
+ return request.AllowAudioStreamCopy
+ && request.EnableAutoStreamCopy
+ && failureReasons == 0;
+ }
+
+ private static TranscodeReason GetAudioStreamCopyFailureReasons(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs)
+ {
+ var request = state.BaseRequest;
+ TranscodeReason reasons = 0;
var maxBitDepth = state.GetRequestedAudioBitDepth(audioStream.Codec);
if (maxBitDepth.HasValue
&& audioStream.BitDepth.HasValue
&& audioStream.BitDepth.Value > maxBitDepth.Value)
{
- return false;
+ reasons |= TranscodeReason.AudioBitDepthNotSupported;
}
// Source and target codecs must match
if (string.IsNullOrEmpty(audioStream.Codec)
|| !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase))
{
- return false;
+ reasons |= TranscodeReason.AudioCodecNotSupported;
}
// Channels must fall within requested value
var channels = state.GetRequestedAudioChannels(audioStream.Codec);
- if (channels.HasValue)
+ if (channels.HasValue
+ && (!audioStream.Channels.HasValue
+ || audioStream.Channels.Value <= 0
+ || audioStream.Channels.Value > channels.Value))
{
- if (!audioStream.Channels.HasValue || audioStream.Channels.Value <= 0)
- {
- return false;
- }
-
- if (audioStream.Channels.Value > channels.Value)
- {
- return false;
- }
+ reasons |= TranscodeReason.AudioChannelsNotSupported;
}
// Sample rate must fall within requested value
- if (request.AudioSampleRate.HasValue)
+ if (request.AudioSampleRate.HasValue
+ && (!audioStream.SampleRate.HasValue
+ || audioStream.SampleRate.Value <= 0
+ || audioStream.SampleRate.Value > request.AudioSampleRate.Value))
{
- if (!audioStream.SampleRate.HasValue || audioStream.SampleRate.Value <= 0)
- {
- return false;
- }
-
- if (audioStream.SampleRate.Value > request.AudioSampleRate.Value)
- {
- return false;
- }
+ reasons |= TranscodeReason.AudioSampleRateNotSupported;
}
// Audio bitrate must fall within requested value
@@ -2668,10 +2679,10 @@ namespace MediaBrowser.Controller.MediaEncoding
&& audioStream.BitRate.HasValue
&& audioStream.BitRate.Value > request.AudioBitRate.Value)
{
- return false;
+ reasons |= TranscodeReason.AudioBitrateNotSupported;
}
- return request.EnableAutoStreamCopy;
+ return reasons;
}
public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec)
@@ -4043,7 +4054,7 @@ namespace MediaBrowser.Controller.MediaEncoding
mainFilters.Add(swDeintFilter);
}
- var outFormat = doCuTonemap ? "yuv420p10le" : "yuv420p";
+ var outFormat = doCuTonemap ? "p010le" : "yuv420p";
var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH);
// sw scale
mainFilters.Add(swScaleFilter);
@@ -7217,8 +7228,9 @@ namespace MediaBrowser.Controller.MediaEncoding
&& !IsCopyCodec(state.OutputVideoCodec)
&& options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio;
+ TranscodeReason audioCopyFailureReasons = 0;
if (state.AudioStream is not null
- && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs)
+ && CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs, out audioCopyFailureReasons)
&& !preventHlsAudioCopy)
{
state.OutputAudioCodec = "copy";
@@ -7232,6 +7244,13 @@ namespace MediaBrowser.Controller.MediaEncoding
{
state.OutputAudioCodec = "copy";
}
+ else if (state.AudioStream is not null && !IsCopyCodec(state.OutputAudioCodec))
+ {
+ // Audio is actually being re-encoded although the playback determination may have considered the source copyable.
+ // Only carry the primary "cannot be passed through" cause - the codec mismatch.
+ // Bitrate/channels/sample-rate/bit-depth copy refusals are consequences of the chosen transcode target.
+ state.AddTranscodeReason(audioCopyFailureReasons & TranscodeReason.AudioCodecNotSupported);
+ }
}
}
@@ -7851,13 +7870,14 @@ namespace MediaBrowser.Controller.MediaEncoding
audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate);
}
- if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
+ var sampleRate = state.OutputAudioSampleRate;
+ if (sampleRate.HasValue)
{
- // opus only supports specific sampling rates
- var sampleRate = state.OutputAudioSampleRate;
- if (sampleRate.HasValue)
+ var sampleRateValue = sampleRate.Value;
+ if (string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
{
- var sampleRateValue = sampleRate.Value switch
+ // opus only supports specific sampling rates
+ sampleRateValue = sampleRate.Value switch
{
<= 8000 => 8000,
<= 12000 => 12000,
@@ -7865,9 +7885,9 @@ namespace MediaBrowser.Controller.MediaEncoding
<= 24000 => 24000,
_ => 48000
};
-
- audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
}
+
+ audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
}
// Copy the movflags from GetProgressiveVideoFullCommandLine
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index 3a1897a244..314cd32903 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -515,6 +515,15 @@ namespace MediaBrowser.Controller.MediaEncoding
public int HlsListSize => 0;
+ /// <summary>
+ /// Adds the specified reason(s) to <see cref="TranscodeReasons"/>.
+ /// </summary>
+ /// <param name="reason">The transcode reason(s) to add.</param>
+ public void AddTranscodeReason(TranscodeReason reason)
+ {
+ _transcodeReasons = TranscodeReasons | reason;
+ }
+
private int? GetMediaStreamCount(MediaStreamType type, int limit)
{
var count = MediaSource.GetStreamCount(type);
diff --git a/MediaBrowser.Controller/Providers/BookInfo.cs b/MediaBrowser.Controller/Providers/BookInfo.cs
index 3055c5d871..7f8151e534 100644
--- a/MediaBrowser.Controller/Providers/BookInfo.cs
+++ b/MediaBrowser.Controller/Providers/BookInfo.cs
@@ -1,11 +1,15 @@
#nullable disable
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ /// <summary>
+ /// The lookup info for books.
+ /// </summary>
public class BookInfo : ItemLookupInfo
{
+ /// <summary>
+ /// Gets or sets the name of the series the book belongs to.
+ /// </summary>
public string SeriesName { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Providers/BoxSetInfo.cs b/MediaBrowser.Controller/Providers/BoxSetInfo.cs
index f43ea67178..22dbdb959e 100644
--- a/MediaBrowser.Controller/Providers/BoxSetInfo.cs
+++ b/MediaBrowser.Controller/Providers/BoxSetInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ /// <summary>
+ /// The lookup info for box sets.
+ /// </summary>
public class BoxSetInfo : ItemLookupInfo
{
}
diff --git a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs
index 42cb523713..834e173ca3 100644
--- a/MediaBrowser.Controller/Providers/IHasLookupInfo.cs
+++ b/MediaBrowser.Controller/Providers/IHasLookupInfo.cs
@@ -1,10 +1,16 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ /// <summary>
+ /// Interface for items that provide lookup info for metadata providers.
+ /// </summary>
+ /// <typeparam name="TLookupInfoType">The type of the lookup info.</typeparam>
public interface IHasLookupInfo<out TLookupInfoType>
where TLookupInfoType : ItemLookupInfo, new()
{
+ /// <summary>
+ /// Gets the lookup info.
+ /// </summary>
+ /// <returns>The lookup info.</returns>
TLookupInfoType GetLookupInfo();
}
}
diff --git a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs
index 6d98af33e4..668160759f 100644
--- a/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs
+++ b/MediaBrowser.Controller/Providers/IPreRefreshProvider.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ /// <summary>
+ /// Marker interface for custom metadata providers that run before the regular metadata refresh.
+ /// </summary>
public interface IPreRefreshProvider : ICustomMetadataProvider
{
}
diff --git a/MediaBrowser.Controller/Providers/MovieInfo.cs b/MediaBrowser.Controller/Providers/MovieInfo.cs
index 20e6b697ad..a33f8bbfe2 100644
--- a/MediaBrowser.Controller/Providers/MovieInfo.cs
+++ b/MediaBrowser.Controller/Providers/MovieInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ /// <summary>
+ /// The lookup info for movies.
+ /// </summary>
public class MovieInfo : ItemLookupInfo
{
}
diff --git a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs
index 11cb71f902..d0eb5cb825 100644
--- a/MediaBrowser.Controller/Providers/PersonLookupInfo.cs
+++ b/MediaBrowser.Controller/Providers/PersonLookupInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ /// <summary>
+ /// The lookup info for persons.
+ /// </summary>
public class PersonLookupInfo : ItemLookupInfo
{
}
diff --git a/MediaBrowser.Controller/Providers/SeriesInfo.cs b/MediaBrowser.Controller/Providers/SeriesInfo.cs
index 976fa175ad..5ca5f0a534 100644
--- a/MediaBrowser.Controller/Providers/SeriesInfo.cs
+++ b/MediaBrowser.Controller/Providers/SeriesInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ /// <summary>
+ /// The lookup info for series.
+ /// </summary>
public class SeriesInfo : ItemLookupInfo
{
}
diff --git a/MediaBrowser.Controller/Providers/TrailerInfo.cs b/MediaBrowser.Controller/Providers/TrailerInfo.cs
index 630850f9db..c30468db6c 100644
--- a/MediaBrowser.Controller/Providers/TrailerInfo.cs
+++ b/MediaBrowser.Controller/Providers/TrailerInfo.cs
@@ -1,7 +1,8 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Controller.Providers
{
+ /// <summary>
+ /// The lookup info for trailers.
+ /// </summary>
public class TrailerInfo : ItemLookupInfo
{
}
diff --git a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
index f56bc71d0d..599c6cd64f 100644
--- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
+++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
@@ -84,7 +84,7 @@ namespace MediaBrowser.LocalMetadata.Images
if (item.SupportsLocalMetadata)
{
// Episode has its own provider
- if (item is Episode || item is Audio || item is Photo)
+ if (item is Episode || (item is Audio && item is not AudioBook) || item is Photo)
{
return false;
}
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index 9dd3dcecba..12a5ab877c 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -8,6 +8,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
@@ -101,7 +102,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
CancellationToken cancellationToken)
{
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
- && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
+ && !string.Equals(PathHelper.GetSafeLeafFileName(a.FileName), a.FileName, StringComparison.Ordinal));
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
await ExtractAllAttachmentsIndividuallyInternal(
@@ -387,7 +388,9 @@ namespace MediaBrowser.MediaEncoding.Attachments
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{
- var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!;
+ var indexName = mediaAttachment.Index.ToString(CultureInfo.InvariantCulture);
+ var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? indexName)
+ ?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
if (!File.Exists(attachmentPath))
{
await ExtractAttachmentInternal(
diff --git a/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs
index a8ff58b091..baea0df8cc 100644
--- a/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/ApplePlatformHelper.cs
@@ -1,9 +1,11 @@
#pragma warning disable CA1031
using System;
+using System.Buffers;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
+using System.Text;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.MediaEncoding.Encoder;
@@ -12,43 +14,43 @@ namespace MediaBrowser.MediaEncoding.Encoder;
/// Helper class for Apple platform specific operations.
/// </summary>
[SupportedOSPlatform("macos")]
-public static class ApplePlatformHelper
+public static partial class ApplePlatformHelper
{
private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"];
- private static string GetSysctlValue(ReadOnlySpan<byte> name)
+ internal static string GetSysctlValue(string name)
{
- IntPtr length = IntPtr.Zero;
+ nuint length = 0;
// Get length of the value
- int osStatus = SysctlByName(name, IntPtr.Zero, ref length, IntPtr.Zero, 0);
-
- if (osStatus != 0)
+ int osStatus = sysctlbyname(name, Span<byte>.Empty, ref length, IntPtr.Zero, 0);
+ if (osStatus != 0 || length == 0)
{
- throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
+ throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}");
}
- IntPtr buffer = Marshal.AllocHGlobal(length.ToInt32());
+ byte[] buffer = ArrayPool<byte>.Shared.Rent((int)length);
try
{
- osStatus = SysctlByName(name, buffer, ref length, IntPtr.Zero, 0);
+ osStatus = sysctlbyname(name, buffer.AsSpan()[..(int)length], ref length, IntPtr.Zero, 0);
if (osStatus != 0)
{
- throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
+ throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}");
}
- return Marshal.PtrToStringAnsi(buffer) ?? string.Empty;
+ if (length < 1)
+ {
+ return string.Empty;
+ }
+
+ ReadOnlySpan<byte> data = buffer.AsSpan()[..(int)(length - 1)];
+ return Encoding.UTF8.GetString(data);
}
finally
{
- Marshal.FreeHGlobal(buffer);
+ ArrayPool<byte>.Shared.Return(buffer);
}
}
- private static int SysctlByName(ReadOnlySpan<byte> name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen)
- {
- return NativeMethods.SysctlByName(name.ToArray(), oldp, ref oldlenp, newp, newlen);
- }
-
/// <summary>
/// Check if the current system has hardware acceleration for AV1 decoding.
/// </summary>
@@ -63,7 +65,7 @@ public static class ApplePlatformHelper
try
{
- string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"u8);
+ string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string");
return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase));
}
catch (NotSupportedException e)
@@ -78,10 +80,7 @@ public static class ApplePlatformHelper
return false;
}
- private static class NativeMethods
- {
- [DllImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
- [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
- internal static extern int SysctlByName(byte[] name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen);
- }
+ [LibraryImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
+ internal static partial int sysctlbyname([MarshalAs(UnmanagedType.LPStr)] string name, Span<byte> oldp, ref nuint oldlenp, IntPtr newp, nuint newlen);
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index 68d6d215b2..1f84b46a2b 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -6,6 +6,7 @@ using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Runtime.Versioning;
+using System.Text;
using System.Text.RegularExpressions;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
@@ -184,8 +185,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
{ "libavdevice", new Version(58, 13) },
{ "libavfilter", new Version(7, 110) },
{ "libswscale", new Version(5, 9) },
- { "libswresample", new Version(3, 9) },
- { "libpostproc", new Version(55, 9) }
+ { "libswresample", new Version(3, 9) }
};
private readonly ILogger _logger;
@@ -645,7 +645,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false,
RedirectStandardInput = redirectStandardIn,
+ StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true
}
})
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 66bf6ebd24..0ddd378352 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -7,6 +7,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
@@ -528,6 +529,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
UseShellExecute = false,
// Must consume both or ffmpeg may hang due to deadlocks.
+ StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
FileName = _ffprobePath,
@@ -926,6 +928,25 @@ namespace MediaBrowser.MediaEncoding.Encoder
throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters.");
}
+ // Normalize invalid PTS from containers for non keyframe only mode
+ if (!enableKeyFrameOnlyExtraction)
+ {
+ var fpsFilterIndex = filterParam.IndexOf("fps=", StringComparison.Ordinal);
+ if (fpsFilterIndex >= 0)
+ {
+ var inputFrameRate = (imageStream.ReferenceFrameRate.HasValue && imageStream.ReferenceFrameRate > 0)
+ ? imageStream.ReferenceFrameRate.Value : 30;
+
+ var setPtsFilter = string.Create(CultureInfo.InvariantCulture, $"setpts=N/{inputFrameRate:F3}/TB,");
+
+ filterParam = filterParam.Insert(fpsFilterIndex, setPtsFilter);
+ }
+ else
+ {
+ throw new InvalidOperationException("EncodingHelper returned invalid filter parameters.");
+ }
+ }
+
try
{
return await ExtractVideoImagesOnIntervalInternal(
diff --git a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
index fc11047a7f..288cd3e189 100644
--- a/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
+++ b/MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj
@@ -9,6 +9,7 @@
<TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 06060988e2..203e565ac7 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -254,16 +254,38 @@ namespace MediaBrowser.MediaEncoding.Probing
{
if (mediaStream.Type == MediaStreamType.Audio && !mediaStream.BitRate.HasValue)
{
- mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Channels);
+ mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Profile, mediaStream.Channels);
}
}
- var videoStreamsBitrate = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).Select(i => i.BitRate ?? 0).Sum();
- // If ffprobe reported the container bitrate as being the same as the video stream bitrate, then it's wrong
- if (videoStreamsBitrate == (info.Bitrate ?? 0))
+ // ffprobe frequently omits the per-stream video bitrate (common in MP4/MKV containers).
+ // Estimate the missing video bitrate as the container bitrate minus the combined stream bitrates.
+ var videoStreams = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).ToList();
+ if (info.Bitrate.HasValue
+ && videoStreams.Count == 1
+ && !videoStreams[0].BitRate.HasValue)
{
- info.InferTotalBitrate(true);
+ var otherStreams = info.MediaStreams
+ .Where(i => i.Type != MediaStreamType.Video && !i.IsExternal)
+ .ToList();
+
+ // Only attribute the leftover bitrate to the video stream if every audio stream's bitrate is known.
+ var audioBitratesKnown = otherStreams
+ .Where(i => i.Type == MediaStreamType.Audio)
+ .All(i => i.BitRate.HasValue);
+
+ if (audioBitratesKnown)
+ {
+ var estimatedVideoBitrate = info.Bitrate.Value - otherStreams.Sum(i => i.BitRate ?? 0);
+ if (estimatedVideoBitrate > 0)
+ {
+ videoStreams[0].BitRate = estimatedVideoBitrate;
+ }
+ }
}
+
+ // If the container bitrate is still unknown, infer it from the sum of the streams.
+ info.InferTotalBitrate();
}
return info;
@@ -316,54 +338,34 @@ namespace MediaBrowser.MediaEncoding.Probing
return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s)));
}
- private static int? GetEstimatedAudioBitrate(string codec, int? channels)
+ internal static int? GetEstimatedAudioBitrate(string codec, string profile, int? channels)
{
- if (!channels.HasValue)
+ if (!channels.HasValue || channels.Value < 1 || string.IsNullOrEmpty(codec))
{
return null;
}
- var channelsValue = channels.Value;
-
- if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase))
- {
- switch (channelsValue)
- {
- case <= 2:
- return 192000;
- case >= 5:
- return 320000;
- }
- }
-
- if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase))
- {
- switch (channelsValue)
- {
- case <= 2:
- return 192000;
- case >= 5:
- return 640000;
- }
- }
+ // Rough typical bitrates used only as a fallback when ffprobe doesn't report a stream bitrate.
+ var channelCount = channels.Value;
+ var isMultichannel = channelCount > 2;
- if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase))
+ return codec.ToLowerInvariant() switch
{
- switch (channelsValue)
- {
- case <= 2:
- return 960000;
- case >= 5:
- return 2880000;
- }
- }
-
- return null;
+ "aac" or "mp3" or "mp2" => isMultichannel ? 320000 : 192000,
+ "ac3" or "eac3" => isMultichannel ? 640000 : 192000,
+ "dts" or "dca" => IsDtsLossless(profile) ? channelCount * 700000 : (isMultichannel ? 1509000 : 768000),
+ "opus" => isMultichannel ? 256000 : 128000,
+ "vorbis" => isMultichannel ? 320000 : 160000,
+ "wmav1" or "wmav2" or "wmapro" => isMultichannel ? 384000 : 192000,
+ "flac" or "alac" => channelCount * 480000,
+ "truehd" or "mlp" => channelCount * 700000,
+ _ => null
+ };
}
+ private static bool IsDtsLossless(string profile)
+ => profile is not null && profile.Contains("HD MA", StringComparison.OrdinalIgnoreCase);
+
private void FetchFromItunesInfo(string xml, MediaInfo info)
{
// Make things simpler and strip out the dtd
@@ -972,10 +974,12 @@ namespace MediaBrowser.MediaEncoding.Probing
bitrate = value;
}
- // The bitrate info of FLAC musics and some videos is included in formatInfo.
+ // The bitrate info of FLAC audio is included in formatInfo.
+ // Don't do this for video streams: formatInfo.BitRate is the overall container
+ // bitrate (video + audio + subtitles + overhead), not the video bitrate.
if (bitrate == 0
&& formatInfo is not null
- && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio)))
+ && isAudio && stream.Type == MediaStreamType.Audio)
{
// If the stream info doesn't have a bitrate get the value from the media format info
if (int.TryParse(formatInfo.BitRate, CultureInfo.InvariantCulture, out value))
@@ -1260,9 +1264,16 @@ namespace MediaBrowser.MediaEncoding.Probing
}
var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION");
- if (TimeSpan.TryParse(duration, out var parsedDuration))
+ if (!string.IsNullOrEmpty(duration))
{
- return parsedDuration.TotalSeconds;
+ // Matroska DURATION tags use nanosecond precision (e.g. "00:00:05.023000000"), but
+ // TimeSpan only supports up to 7 fractional digits (ticks). Trim the surplus digits so
+ // these durations parse instead of being silently dropped.
+ duration = DurationOverPrecisionRegex().Replace(duration, "$1");
+ if (TimeSpan.TryParse(duration, CultureInfo.InvariantCulture, out var parsedDuration))
+ {
+ return parsedDuration.TotalSeconds;
+ }
}
return null;
@@ -1630,7 +1641,7 @@ namespace MediaBrowser.MediaEncoding.Probing
// Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/
// DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None)
- if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.AdjustToUniversal, out var parsedDate))
+ if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsedDate))
{
video.PremiereDate = parsedDate;
}
@@ -1764,5 +1775,8 @@ namespace MediaBrowser.MediaEncoding.Probing
[GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")]
private static partial Regex PerformerRegex();
+
+ [GeneratedRegex(@"(\.\d{7})\d+")]
+ private static partial Regex DurationOverPrecisionRegex();
}
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
index bd13437fb6..7566616f70 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/ISubtitleParser.cs
@@ -1,7 +1,7 @@
#pragma warning disable CS1591
using System.IO;
-using MediaBrowser.Model.MediaInfo;
+using Nikse.SubtitleEdit.Core.Common;
namespace MediaBrowser.MediaEncoding.Subtitles
{
@@ -12,8 +12,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
/// </summary>
/// <param name="stream">The stream.</param>
/// <param name="fileExtension">The file extension.</param>
- /// <returns>SubtitleTrackInfo.</returns>
- SubtitleTrackInfo Parse(Stream stream, string fileExtension);
+ /// <returns>The parsed subtitle.</returns>
+ Subtitle Parse(Stream stream, string fileExtension);
/// <summary>
/// Determines whether the file extension is supported by the parser.
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
index d060b247da..d75eea5904 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEditParser.cs
@@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using System.IO;
using System.Linq;
using Jellyfin.Extensions;
-using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using Nikse.SubtitleEdit.Core.Common;
using SubtitleFormat = Nikse.SubtitleEdit.Core.SubtitleFormats.SubtitleFormat;
@@ -30,7 +28,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
/// <inheritdoc />
- public SubtitleTrackInfo Parse(Stream stream, string fileExtension)
+ public Subtitle Parse(Stream stream, string fileExtension)
{
var subtitle = new Subtitle();
var lines = stream.ReadAllLines().ToList();
@@ -76,21 +74,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
throw new ArgumentException("Unsupported format: " + fileExtension);
}
- var trackInfo = new SubtitleTrackInfo();
- int len = subtitle.Paragraphs.Count;
- var trackEvents = new SubtitleTrackEvent[len];
- for (int i = 0; i < len; i++)
- {
- var p = subtitle.Paragraphs[i];
- trackEvents[i] = new SubtitleTrackEvent(p.Number.ToString(CultureInfo.InvariantCulture), p.Text)
- {
- StartPositionTicks = p.StartTime.TimeSpan.Ticks,
- EndPositionTicks = p.EndTime.TimeSpan.Ticks
- };
- }
-
- trackInfo.TrackEvents = trackEvents;
- return trackInfo;
+ return subtitle;
}
/// <inheritdoc />
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 67e323177b..bd516f0a9f 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -73,7 +73,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
_serverConfigurationManager = serverConfigurationManager;
}
- private MemoryStream ConvertSubtitles(
+ internal MemoryStream ConvertSubtitles(
Stream stream,
SubtitleInfo inputInfo,
string outputFormat,
@@ -81,7 +81,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
long endTimeTicks,
bool preserveOriginalTimestamps)
{
- var subtitle = Subtitle.Parse(stream, Path.GetExtension(inputInfo.Path));
+ var subtitle = _subtitleParser.Parse(stream, inputInfo.Format);
FilterEvents(subtitle, startTimeTicks, endTimeTicks, preserveOriginalTimestamps);
@@ -163,28 +163,36 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return (stream, fileInfo);
}
- private async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
+ internal async Task<Stream> GetSubtitleStream(SubtitleInfo fileInfo, CancellationToken cancellationToken)
{
- if (fileInfo.Protocol == MediaProtocol.Http)
+ if (fileInfo.IsExternal && MediaStream.IsTextFormat(fileInfo.Format))
{
- var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
+ var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false);
var detected = result.Detected;
- if (detected is not null)
+ var stream = fileInfo.Protocol == MediaProtocol.Http
+ ? await _httpClientFactory.CreateClient(NamedClient.Default)
+ .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken)
+ .ConfigureAwait(false)
+ : AsyncFile.OpenRead(fileInfo.Path);
+
+ // Short-circuit when the file is already UTF-8/ASCII.
+ if (detected is null
+ || string.Equals(detected.EncodingName, "utf-8", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(detected.EncodingName, "ascii", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(detected.EncodingName, "us-ascii", StringComparison.OrdinalIgnoreCase))
{
- _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
+ return stream;
+ }
- using var stream = await _httpClientFactory.CreateClient(NamedClient.Default)
- .GetStreamAsync(new Uri(fileInfo.Path), cancellationToken)
- .ConfigureAwait(false);
+ _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
- await using (stream.ConfigureAwait(false))
- {
- using var reader = new StreamReader(stream, detected.Encoding);
- var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
+ {
+ using var reader = new StreamReader(stream, detected.Encoding);
+ var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
- return new MemoryStream(Encoding.UTF8.GetBytes(text));
- }
+ return new MemoryStream(Encoding.UTF8.GetBytes(text));
}
}
@@ -445,98 +453,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles
encodingParam = " -sub_charenc " + encodingParam;
}
- int exitCode;
-
- using (var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- CreateNoWindow = true,
- UseShellExecute = false,
- FileName = _mediaEncoder.EncoderPath,
- Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
- WindowStyle = ProcessWindowStyle.Hidden,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
- {
- _logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- try
- {
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error starting ffmpeg");
-
- throw;
- }
-
- try
- {
- var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
- await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
- exitCode = process.ExitCode;
- }
- catch (OperationCanceledException)
- {
- process.Kill(true);
- exitCode = -1;
- }
- }
-
- var failed = false;
+ var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
- if (exitCode == -1)
- {
- failed = true;
-
- if (File.Exists(outputPath))
- {
- try
- {
- _logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
- _fileSystem.DeleteFile(outputPath);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting converted subtitle {Path}", 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)
- {
- _logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
-
- throw new FfmpegException(
- string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath));
- }
-
- await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
+ await ExtractSubtitlesForFile(
+ inputPath,
+ args,
+ [outputPath],
+ cancellationToken).ConfigureAwait(false);
WriteCacheMeta(outputPath, inputPath);
-
- _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
}
private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
@@ -727,7 +652,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List<string>();
var args = string.Format(
CultureInfo.InvariantCulture,
- "-i {0}",
+ "-y -i {0}",
inputPath);
foreach (var subtitleStream in subtitleStreams)
@@ -781,50 +706,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
private async Task ExtractSubtitlesForFile(
string inputPath,
string args,
- List<string> outputPaths,
+ IReadOnlyList<string> outputPaths,
CancellationToken cancellationToken)
{
- int exitCode;
-
- using (var process = new Process
- {
- StartInfo = new ProcessStartInfo
- {
- CreateNoWindow = true,
- UseShellExecute = false,
- FileName = _mediaEncoder.EncoderPath,
- Arguments = args,
- WindowStyle = ProcessWindowStyle.Hidden,
- ErrorDialog = false
- },
- EnableRaisingEvents = true
- })
- {
- _logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
-
- try
- {
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error starting ffmpeg");
-
- throw;
- }
-
- try
- {
- var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
- await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
- exitCode = process.ExitCode;
- }
- catch (OperationCanceledException)
- {
- process.Kill(true);
- exitCode = -1;
- }
- }
+ var (exitCode, ffmpegError) = await RunSubtitleExtractionProcess(args, cancellationToken).ConfigureAwait(false);
var failed = false;
@@ -884,6 +769,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (failed)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (!string.IsNullOrWhiteSpace(ffmpegError))
+ {
+ _logger.LogError("ffmpeg subtitle extraction failed for {InputPath}: {FfmpegOutput}", inputPath, ffmpegError);
+ }
+
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
}
@@ -941,16 +833,38 @@ namespace MediaBrowser.MediaEncoding.Subtitles
ArgumentException.ThrowIfNullOrEmpty(outputPath);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
-
var processArgs = string.Format(
CultureInfo.InvariantCulture,
- "-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
+ "-y -i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
inputPath,
subtitleStreamIndex,
outputCodec,
outputPath);
+ await ExtractSubtitlesForFile(
+ inputPath,
+ processArgs,
+ [outputPath],
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output.
+ /// </summary>
+ /// <remarks>
+ /// stdin is redirected and closed, and <c>-nostdin</c> is prepended to the arguments, so ffmpeg can never
+ /// block reading an inherited stdin handle (which happens when Jellyfin runs as a service, e.g. under NSSM,
+ /// and stalls subtitle extraction until the timeout). stderr is redirected and drained so a full pipe buffer
+ /// cannot deadlock ffmpeg and so its output can be surfaced on failure; stdout is left un-redirected as it is
+ /// unused for subtitle extraction.
+ /// </remarks>
+ /// <param name="arguments">The ffmpeg command line arguments.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The ffmpeg exit code (-1 on timeout) and its captured stderr output.</returns>
+ private async Task<(int ExitCode, string StandardError)> RunSubtitleExtractionProcess(string arguments, CancellationToken cancellationToken)
+ {
int exitCode;
+ var standardError = string.Empty;
using (var process = new Process
{
@@ -958,8 +872,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
CreateNoWindow = true,
UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardError = true,
FileName = _mediaEncoder.EncoderPath,
- Arguments = processArgs,
+ Arguments = "-nostdin " + arguments,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
@@ -975,14 +891,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
-
throw;
}
+ // Close stdin so ffmpeg observes EOF instead of blocking on an inherited handle.
+ process.StandardInput.Close();
+
+ // Begin draining stderr before waiting for exit; a full stderr pipe buffer would otherwise deadlock ffmpeg.
+ var standardErrorTask = process.StandardError.ReadToEndAsync(CancellationToken.None);
+ var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
+ using var waitSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ waitSource.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes));
+
try
{
- var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
- await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
+ await process.WaitForExitAsync(waitSource.Token).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
@@ -990,59 +913,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
process.Kill(true);
exitCode = -1;
}
- }
-
- var failed = false;
-
- if (exitCode == -1)
- {
- 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);
- }
- }
- 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)
- {
+ standardError = await standardErrorTask.ConfigureAwait(false);
}
- catch (IOException ex)
+ catch (OperationCanceledException)
{
- _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
+ // Reading ffmpeg output was cancelled; nothing more to capture.
}
}
- if (failed)
- {
- _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
-
- throw new FfmpegException(
- string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath));
- }
-
- _logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
-
- if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
- {
- await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
- }
+ return (exitCode, standardError);
}
/// <summary>
@@ -1104,7 +986,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
- var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
+ var result = await DetectCharset(path, cancellationToken).ConfigureAwait(false);
var charset = result.Detected?.EncodingName ?? string.Empty;
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
@@ -1120,8 +1002,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return charset;
}
- private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken)
+ private async Task<DetectionResult> DetectCharset(string path, CancellationToken cancellationToken)
{
+ var protocol = _mediaSourceManager.GetPathProtocol(path);
switch (protocol)
{
case MediaProtocol.Http:
@@ -1141,7 +1024,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
default:
- throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
+ throw new NotSupportedException($"Unsupported protocol: {protocol}");
}
}
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index defd855ec0..78bb881ec2 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -424,6 +424,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
// Must consume both stdout and stderr or deadlocks may occur
// RedirectStandardOutput = true,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true,
RedirectStandardInput = true,
FileName = _mediaEncoder.EncoderPath,
diff --git a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs
index 485a4d2f80..c67f379fde 100644
--- a/MediaBrowser.Model/Configuration/ImageSavingConvention.cs
+++ b/MediaBrowser.Model/Configuration/ImageSavingConvention.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Configuration
{
+ /// <summary>
+ /// The convention used for naming saved images.
+ /// </summary>
public enum ImageSavingConvention
{
+ /// <summary>
+ /// The legacy naming convention.
+ /// </summary>
Legacy,
+
+ /// <summary>
+ /// The naming convention compatible with other media servers and metadata managers.
+ /// </summary>
Compatible
}
}
diff --git a/MediaBrowser.Model/Dlna/CodecType.cs b/MediaBrowser.Model/Dlna/CodecType.cs
index c9f090e4cc..12730a76fa 100644
--- a/MediaBrowser.Model/Dlna/CodecType.cs
+++ b/MediaBrowser.Model/Dlna/CodecType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ /// <summary>
+ /// The codec type of a codec profile.
+ /// </summary>
public enum CodecType
{
+ /// <summary>
+ /// The profile applies to a video codec.
+ /// </summary>
Video = 0,
+
+ /// <summary>
+ /// The profile applies to the audio codec of a video stream.
+ /// </summary>
VideoAudio = 1,
+
+ /// <summary>
+ /// The profile applies to an audio codec.
+ /// </summary>
Audio = 2
}
}
diff --git a/MediaBrowser.Model/Dlna/EncodingContext.cs b/MediaBrowser.Model/Dlna/EncodingContext.cs
index 79ca6366d7..1408333d2e 100644
--- a/MediaBrowser.Model/Dlna/EncodingContext.cs
+++ b/MediaBrowser.Model/Dlna/EncodingContext.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ /// <summary>
+ /// The encoding context.
+ /// </summary>
public enum EncodingContext
{
+ /// <summary>
+ /// The media is transcoded on the fly and delivered as a stream.
+ /// </summary>
Streaming = 0,
+
+ /// <summary>
+ /// The media is transcoded to a static file.
+ /// </summary>
Static = 1
}
}
diff --git a/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs b/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs
index 300fab5c50..a28f422a2b 100644
--- a/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs
+++ b/MediaBrowser.Model/Dlna/PlaybackErrorCode.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ /// <summary>
+ /// The playback error code.
+ /// </summary>
public enum PlaybackErrorCode
{
+ /// <summary>
+ /// Playback of the item is not allowed.
+ /// </summary>
NotAllowed = 0,
+
+ /// <summary>
+ /// No stream compatible with the device profile was found.
+ /// </summary>
NoCompatibleStream = 1,
+
+ /// <summary>
+ /// The rate limit has been exceeded.
+ /// </summary>
RateLimitExceeded = 2
}
}
diff --git a/MediaBrowser.Model/Dlna/ResolutionOptions.cs b/MediaBrowser.Model/Dlna/ResolutionOptions.cs
index 774592abc7..b161b4a1e4 100644
--- a/MediaBrowser.Model/Dlna/ResolutionOptions.cs
+++ b/MediaBrowser.Model/Dlna/ResolutionOptions.cs
@@ -1,11 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ /// <summary>
+ /// The resolution constraints.
+ /// </summary>
public class ResolutionOptions
{
+ /// <summary>
+ /// Gets or sets the maximum width.
+ /// </summary>
public int? MaxWidth { get; set; }
+ /// <summary>
+ /// Gets or sets the maximum height.
+ /// </summary>
public int? MaxHeight { get; set; }
}
}
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index d875bbe8ed..59f97d8c7c 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -951,6 +951,10 @@ namespace MediaBrowser.Model.Dlna
}
playlistItem.VideoCodecs = videoCodecs;
+ if (videoStream is not null && !ContainerHelper.ContainsContainer(videoCodecs, false, videoStream.Codec))
+ {
+ playlistItem.TranscodeReasons |= TranscodeReason.VideoCodecNotSupported;
+ }
// Copy video codec options as a starting point, this applies to transcode and direct-stream
playlistItem.MaxFramerate = videoStream?.ReferenceFrameRate;
@@ -999,6 +1003,10 @@ namespace MediaBrowser.Model.Dlna
var directAudioFailures = audioStreamWithSupportedCodec is null ? default : GetCompatibilityAudioCodec(options, item, container ?? string.Empty, audioStreamWithSupportedCodec, null, true, false);
playlistItem.TranscodeReasons |= directAudioFailures;
+ if (audioStream is not null && audioStreamWithSupportedCodec is null)
+ {
+ playlistItem.TranscodeReasons |= TranscodeReason.AudioCodecNotSupported;
+ }
var directAudioStreamSatisfied = audioStreamWithSupportedCodec is not null && !channelsExceedsLimit
&& directAudioFailures == 0;
diff --git a/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs b/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs
index cc0c6069bf..1563ffd17a 100644
--- a/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs
+++ b/MediaBrowser.Model/Dlna/TranscodeSeekInfo.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dlna
{
+ /// <summary>
+ /// The transcode seek info.
+ /// </summary>
public enum TranscodeSeekInfo
{
+ /// <summary>
+ /// The seek method is chosen automatically.
+ /// </summary>
Auto = 0,
+
+ /// <summary>
+ /// Seeking is performed by byte position.
+ /// </summary>
Bytes = 1
}
}
diff --git a/MediaBrowser.Model/Dto/IHasServerId.cs b/MediaBrowser.Model/Dto/IHasServerId.cs
index c754d276c5..49452d736a 100644
--- a/MediaBrowser.Model/Dto/IHasServerId.cs
+++ b/MediaBrowser.Model/Dto/IHasServerId.cs
@@ -1,10 +1,15 @@
#nullable disable
-#pragma warning disable CS1591
namespace MediaBrowser.Model.Dto
{
+ /// <summary>
+ /// Interface for DTOs that reference the id of the server they originate from.
+ /// </summary>
public interface IHasServerId
{
+ /// <summary>
+ /// Gets the server id.
+ /// </summary>
string ServerId { get; }
}
}
diff --git a/MediaBrowser.Model/Dto/MediaSourceType.cs b/MediaBrowser.Model/Dto/MediaSourceType.cs
index 42314d5198..ca6649e64b 100644
--- a/MediaBrowser.Model/Dto/MediaSourceType.cs
+++ b/MediaBrowser.Model/Dto/MediaSourceType.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dto
{
+ /// <summary>
+ /// The type of a media source.
+ /// </summary>
public enum MediaSourceType
{
+ /// <summary>
+ /// A default media source.
+ /// </summary>
Default = 0,
+
+ /// <summary>
+ /// A grouping of media sources.
+ /// </summary>
Grouping = 1,
+
+ /// <summary>
+ /// A placeholder media source, for example a disc that has to be inserted.
+ /// </summary>
Placeholder = 2
}
}
diff --git a/MediaBrowser.Model/Dto/RatingType.cs b/MediaBrowser.Model/Dto/RatingType.cs
index 033776f9c6..2c2b7b705d 100644
--- a/MediaBrowser.Model/Dto/RatingType.cs
+++ b/MediaBrowser.Model/Dto/RatingType.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Dto
{
+ /// <summary>
+ /// The type of a community rating.
+ /// </summary>
public enum RatingType
{
+ /// <summary>
+ /// The rating is a numeric score.
+ /// </summary>
Score,
+
+ /// <summary>
+ /// The rating is based on likes.
+ /// </summary>
Likes
}
}
diff --git a/MediaBrowser.Model/Library/PlayAccess.cs b/MediaBrowser.Model/Library/PlayAccess.cs
index a2f263ce54..22daaf7254 100644
--- a/MediaBrowser.Model/Library/PlayAccess.cs
+++ b/MediaBrowser.Model/Library/PlayAccess.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Library
{
+ /// <summary>
+ /// The play access of an item.
+ /// </summary>
public enum PlayAccess
{
+ /// <summary>
+ /// The item can be played.
+ /// </summary>
Full = 0,
+
+ /// <summary>
+ /// The item cannot be played.
+ /// </summary>
None = 1
}
}
diff --git a/MediaBrowser.Model/LiveTv/DayPattern.cs b/MediaBrowser.Model/LiveTv/DayPattern.cs
index 17efe39088..dab69e8974 100644
--- a/MediaBrowser.Model/LiveTv/DayPattern.cs
+++ b/MediaBrowser.Model/LiveTv/DayPattern.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.LiveTv
{
+ /// <summary>
+ /// The day pattern of a recurring timer.
+ /// </summary>
public enum DayPattern
{
+ /// <summary>
+ /// Every day.
+ /// </summary>
Daily,
+
+ /// <summary>
+ /// Monday through Friday.
+ /// </summary>
Weekdays,
+
+ /// <summary>
+ /// Saturday and Sunday.
+ /// </summary>
Weekends
}
}
diff --git a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs
index 72a0e2d7bf..a3df1dc411 100644
--- a/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs
+++ b/MediaBrowser.Model/LiveTv/LiveTvServiceStatus.cs
@@ -1,10 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.LiveTv
{
+ /// <summary>
+ /// The status of a live TV service.
+ /// </summary>
public enum LiveTvServiceStatus
{
+ /// <summary>
+ /// The service is available.
+ /// </summary>
Ok = 0,
+
+ /// <summary>
+ /// The service is unavailable.
+ /// </summary>
Unavailable = 1
}
}
diff --git a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs
index b7ee5747ab..1988dd8078 100644
--- a/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs
+++ b/MediaBrowser.Model/MediaInfo/TransportStreamTimestamp.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.MediaInfo
{
+ /// <summary>
+ /// The type of timestamps used in a transport stream.
+ /// </summary>
public enum TransportStreamTimestamp
{
+ /// <summary>
+ /// The stream contains no timestamps.
+ /// </summary>
None,
+
+ /// <summary>
+ /// The stream contains zero-value timestamps.
+ /// </summary>
Zero,
+
+ /// <summary>
+ /// The stream contains valid timestamps.
+ /// </summary>
Valid
}
}
diff --git a/MediaBrowser.Model/Session/MessageCommand.cs b/MediaBrowser.Model/Session/MessageCommand.cs
index cc9db8e6c5..e041a9cccd 100644
--- a/MediaBrowser.Model/Session/MessageCommand.cs
+++ b/MediaBrowser.Model/Session/MessageCommand.cs
@@ -1,17 +1,28 @@
#nullable disable
-#pragma warning disable CS1591
using System.ComponentModel.DataAnnotations;
namespace MediaBrowser.Model.Session
{
+ /// <summary>
+ /// A command to display a message on a client.
+ /// </summary>
public class MessageCommand
{
+ /// <summary>
+ /// Gets or sets the message header.
+ /// </summary>
public string Header { get; set; }
+ /// <summary>
+ /// Gets or sets the message text.
+ /// </summary>
[Required(AllowEmptyStrings = false)]
public string Text { get; set; }
+ /// <summary>
+ /// Gets or sets the timeout in milliseconds after which the message should be dismissed.
+ /// </summary>
public long? TimeoutMs { get; set; }
}
}
diff --git a/MediaBrowser.Model/Session/PlayMethod.cs b/MediaBrowser.Model/Session/PlayMethod.cs
index 8067627843..2bd11cc91a 100644
--- a/MediaBrowser.Model/Session/PlayMethod.cs
+++ b/MediaBrowser.Model/Session/PlayMethod.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Session
{
+ /// <summary>
+ /// The play method.
+ /// </summary>
public enum PlayMethod
{
+ /// <summary>
+ /// The media is transcoded before it is sent to the client.
+ /// </summary>
Transcode = 0,
+
+ /// <summary>
+ /// The media is remuxed into a compatible container but the streams are not re-encoded.
+ /// </summary>
DirectStream = 1,
+
+ /// <summary>
+ /// The media is sent to the client as-is.
+ /// </summary>
DirectPlay = 2
}
}
diff --git a/MediaBrowser.Model/Session/PlaystateRequest.cs b/MediaBrowser.Model/Session/PlaystateRequest.cs
index ba2c024b76..040affa144 100644
--- a/MediaBrowser.Model/Session/PlaystateRequest.cs
+++ b/MediaBrowser.Model/Session/PlaystateRequest.cs
@@ -1,11 +1,18 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Session
{
+ /// <summary>
+ /// A request to change the playstate of a session.
+ /// </summary>
public class PlaystateRequest
{
+ /// <summary>
+ /// Gets or sets the playstate command.
+ /// </summary>
public PlaystateCommand Command { get; set; }
+ /// <summary>
+ /// Gets or sets the seek position in ticks.
+ /// </summary>
public long? SeekPositionTicks { get; set; }
/// <summary>
diff --git a/MediaBrowser.Model/Session/QueueItem.cs b/MediaBrowser.Model/Session/QueueItem.cs
index 43920a8464..b9f3181da0 100644
--- a/MediaBrowser.Model/Session/QueueItem.cs
+++ b/MediaBrowser.Model/Session/QueueItem.cs
@@ -1,13 +1,21 @@
#nullable disable
-#pragma warning disable CS1591
using System;
namespace MediaBrowser.Model.Session;
+/// <summary>
+/// An item in a play queue.
+/// </summary>
public record QueueItem
{
+ /// <summary>
+ /// Gets or sets the item id.
+ /// </summary>
public Guid Id { get; set; }
+ /// <summary>
+ /// Gets or sets the playlist item id.
+ /// </summary>
public string PlaylistItemId { get; set; }
}
diff --git a/MediaBrowser.Model/Session/RepeatMode.cs b/MediaBrowser.Model/Session/RepeatMode.cs
index c6e173d6b8..c6c657d220 100644
--- a/MediaBrowser.Model/Session/RepeatMode.cs
+++ b/MediaBrowser.Model/Session/RepeatMode.cs
@@ -1,11 +1,23 @@
-#pragma warning disable CS1591
-
namespace MediaBrowser.Model.Session
{
+ /// <summary>
+ /// The repeat mode of a play queue.
+ /// </summary>
public enum RepeatMode
{
+ /// <summary>
+ /// Nothing is repeated.
+ /// </summary>
RepeatNone = 0,
+
+ /// <summary>
+ /// The whole queue is repeated.
+ /// </summary>
RepeatAll = 1,
+
+ /// <summary>
+ /// The current item is repeated.
+ /// </summary>
RepeatOne = 2
}
}
diff --git a/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs b/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs
deleted file mode 100644
index 0d096241d6..0000000000
--- a/MediaBrowser.Providers/Books/ComicServiceRegistrator.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using MediaBrowser.Controller;
-using MediaBrowser.Controller.Plugins;
-using MediaBrowser.Providers.Books.ComicBookInfo;
-using MediaBrowser.Providers.Books.ComicInfo;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace MediaBrowser.Providers.Books;
-
-/// <inheritdoc />
-public class ComicServiceRegistrator : IPluginServiceRegistrator
-{
- /// <inheritdoc />
- public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
- {
- // register the generic local metadata provider for comic files
- serviceCollection.AddSingleton<ComicProvider>();
-
- // register the actual implementations of the local metadata provider for comic files
- serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
- serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
- serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
- }
-}
diff --git a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
index 15ea2ce5ab..6266413dfc 100644
--- a/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
+++ b/MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs
@@ -2,6 +2,7 @@ using System;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Xml;
using Jellyfin.Data.Enums;
@@ -17,7 +18,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
/// 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>
+ public partial class OpfReader<TCategoryName>
{
private const string DcNamespace = @"http://purl.org/dc/elements/1.1/";
private const string OpfNamespace = @"http://www.idpf.org/2007/opf";
@@ -42,6 +43,9 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
_namespaceManager.AddNamespace("opf", OpfNamespace);
}
+ [GeneratedRegex(@"(?<=\p{L})\.(?!\s|$)")]
+ private static partial Regex InitialsRegex();
+
/// <summary>
/// Checks for the existence of a cover image.
/// </summary>
@@ -125,7 +129,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
ReadStringInto("//dc:date", date =>
{
- if (DateTime.TryParse(date, out var dateValue))
+ if (DateTime.TryParse(date, CultureInfo.InvariantCulture, out var dateValue))
{
book.PremiereDate = dateValue.Date;
book.ProductionYear = dateValue.Date.Year;
@@ -229,11 +233,23 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
{
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);
+ var normalizedCreators = creator.InnerText
+ .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Select(fullName =>
+ {
+ if (fullName.Split(',', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) is [var lastName, var firstName])
+ {
+ fullName = $"{firstName} {lastName}";
+ }
+
+ return InitialsRegex().Replace(fullName, ". ");
+ });
+
+ foreach (var fullName in normalizedCreators)
+ {
+ book.AddPerson(new PersonInfo { Name = fullName, Type = GetRole(role) });
+ }
}
}
}
diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs
index 913a104a0d..af31e373ef 100644
--- a/MediaBrowser.Providers/Lyric/LyricManager.cs
+++ b/MediaBrowser.Providers/Lyric/LyricManager.cs
@@ -398,7 +398,7 @@ public class LyricManager : ILyricManager
{
var mediaFolderPath = Path.GetFullPath(Path.Combine(audio.ContainingFolderPath, saveFileName));
// TODO: Add some error handling to the API user: return BadRequest("Could not save lyric, bad path.");
- if (mediaFolderPath.StartsWith(audio.ContainingFolderPath, StringComparison.Ordinal))
+ if (PathHelper.IsContainedIn(audio.ContainingFolderPath, mediaFolderPath))
{
savePaths.Add(mediaFolderPath);
}
@@ -407,7 +407,7 @@ public class LyricManager : ILyricManager
var internalPath = Path.GetFullPath(Path.Combine(audio.GetInternalMetadataPath(), saveFileName));
// TODO: Add some error to the user: return BadRequest("Could not save lyric, bad path.");
- if (internalPath.StartsWith(audio.GetInternalMetadataPath(), StringComparison.Ordinal))
+ if (PathHelper.IsContainedIn(audio.GetInternalMetadataPath(), internalPath))
{
savePaths.Add(internalPath);
}
diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs
index d9a8c044b9..aa4ca5afd8 100644
--- a/MediaBrowser.Providers/Manager/ImageSaver.cs
+++ b/MediaBrowser.Providers/Manager/ImageSaver.cs
@@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Manager
{
ArgumentException.ThrowIfNullOrEmpty(mimeType);
- var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && item is not Audio;
+ var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && (item is AudioBook || item is not Audio);
if (type != ImageType.Primary && item is Episode)
{
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index c2e523cfaf..a438a94c40 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -680,11 +680,18 @@ namespace MediaBrowser.Providers.Manager
return providers;
}
- protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, ImageRefreshOptions options)
+ protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, MetadataRefreshOptions options)
{
// Get providers to refresh
var providers = allImageProviders.Where(i => i is not ILocalImageProvider);
+ // When identifying, run the provider the user picked first so the correct image is used.
+ if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName))
+ {
+ providers = providers
+ .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1);
+ }
+
var dateLastImageRefresh = item.DateLastRefreshed;
// Run all if either of these flags are true
@@ -831,8 +838,16 @@ namespace MediaBrowser.Providers.Manager
var isLocalLocked = temp.Item.IsLocked;
if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly))
{
- var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
- .ConfigureAwait(false);
+ var remoteProviders = providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>();
+
+ // When identifying, run the provider the user picked first so the correct IDs are used.
+ if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName))
+ {
+ remoteProviders = remoteProviders
+ .OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1);
+ }
+
+ var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false);
refreshResult.UpdateType |= remoteResult.UpdateType;
refreshResult.ErrorMessage = remoteResult.ErrorMessage;
@@ -1108,7 +1123,7 @@ namespace MediaBrowser.Providers.Manager
{
if (replaceData || !target.RunTimeTicks.HasValue)
{
- if (target is not Audio && target is not Video)
+ if (target is not Audio && target is not Video && target is not Book)
{
target.RunTimeTicks = source.RunTimeTicks;
}
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index df51dd8421..2b0f480b1c 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -21,6 +21,7 @@
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Newtonsoft.Json" />
+ <PackageReference Include="PDFtoImage" />
<PackageReference Include="PlaylistsNET" />
<PackageReference Include="SharpCompress" />
<PackageReference Include="z440.atl.core" />
diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
index 0ecbb6f068..b70cba5b3b 100644
--- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
+++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
@@ -549,7 +549,7 @@ namespace MediaBrowser.Providers.MediaInfo
var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null);
var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics;
if (!string.IsNullOrWhiteSpace(lyrics)
- && tryExtractEmbeddedLyrics)
+ && (tryExtractEmbeddedLyrics || options.ReplaceAllMetadata))
{
await _lyricManager.SaveLyricAsync(audio, "lrc", lyrics).ConfigureAwait(false);
}
diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
index 789df8f061..221c6bff5e 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -24,6 +24,8 @@ using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
+using PDFtoImage;
+using SharpCompress.Archives;
namespace MediaBrowser.Providers.MediaInfo
{
@@ -37,6 +39,7 @@ namespace MediaBrowser.Providers.MediaInfo
ICustomMetadataProvider<Video>,
ICustomMetadataProvider<Audio>,
ICustomMetadataProvider<AudioBook>,
+ ICustomMetadataProvider<Book>,
IHasOrder,
IForcedProvider,
IPreRefreshProvider,
@@ -214,6 +217,57 @@ namespace MediaBrowser.Providers.MediaInfo
return FetchAudioInfo(item, options, cancellationToken);
}
+ /// <inheritdoc />
+ public Task<ItemUpdateType> FetchAsync(Book item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+ {
+ if (item.IsVirtualItem || !item.IsFileProtocol)
+ {
+ return _cachedTask;
+ }
+
+ long pageCount;
+ switch (Path.GetExtension(item.Path).ToLowerInvariant())
+ {
+ case ".cb7":
+ case ".cbr":
+ case ".cbt":
+ case ".cbz":
+ using (var stream = File.OpenRead(item.Path))
+ using (var archive = ArchiveFactory.OpenArchive(stream))
+ {
+ pageCount = archive.Entries.Count(e => !e.IsDirectory);
+ }
+
+ break;
+
+#pragma warning disable CA1416
+ case ".pdf":
+ using (var stream = File.OpenRead(item.Path))
+ {
+ pageCount = Conversion.GetPageCount(stream);
+ }
+
+ break;
+#pragma warning restore CA1416
+
+ case ".epub":
+ // TODO process CFI and store as a string when multiple progress types are supported
+ // current progress value is percentage stored as a proportion of one second worth of ticks
+ item.RunTimeTicks = TimeSpan.TicksPerSecond;
+
+ return Task.FromResult(ItemUpdateType.MetadataImport);
+
+ default:
+ return _cachedTask;
+ }
+
+ // TODO use page count without modification when multiple progress types are supported
+ // book players report page count and the web client multiplies that value by 10000 to convert the expected milliseconds into ticks
+ item.RunTimeTicks = pageCount * 10000;
+
+ return Task.FromResult(ItemUpdateType.MetadataImport);
+ }
+
/// <summary>
/// Fetches video information for an item.
/// </summary>
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
index f0e159f098..21b822c97c 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
@@ -181,7 +181,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
ParentIndexNumber = seasonNumber,
IndexNumberEnd = info.IndexNumberEnd,
Name = episodeResult.Name,
- PremiereDate = episodeResult.AirDate,
+ PremiereDate = episodeResult.AirDate.HasValue
+ ? DateTime.SpecifyKind(episodeResult.AirDate.Value, DateTimeKind.Local).ToUniversalTime()
+ : null,
ProductionYear = episodeResult.AirDate?.Year,
Overview = episodeResult.Overview,
CommunityRating = Convert.ToSingle(episodeResult.VoteAverage)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index 4229a1dc82..9bb15ca479 100644..100755
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -256,11 +256,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
series.Overview = seriesResult.Overview;
+ var studios = Enumerable.Empty<string>();
+
if (seriesResult.Networks is not null)
{
- series.Studios = seriesResult.Networks.Select(i => i.Name).ToArray();
+ studios = studios.Concat(seriesResult.Networks.Select(i => i.Name).OfType<string>());
+ }
+
+ if (seriesResult.ProductionCompanies is not null)
+ {
+ studios = studios.Concat(seriesResult.ProductionCompanies.Select(i => i.Name).OfType<string>());
}
+ series.SetStudios(studios);
+
if (seriesResult.Genres is not null)
{
series.Genres = seriesResult.Genres.Select(i => i.Name).ToArray();
@@ -430,6 +439,31 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
yield return personInfo;
}
}
+
+ if (seriesResult.CreatedBy is not null)
+ {
+ foreach (var person in seriesResult.CreatedBy)
+ {
+ if (string.IsNullOrWhiteSpace(person.Name))
+ {
+ continue;
+ }
+
+ var personInfo = new PersonInfo
+ {
+ Name = person.Name.Trim(),
+ Type = PersonKind.Creator,
+ ImageUrl = _tmdbClientManager.GetProfileUrl(person.ProfilePath)
+ };
+
+ if (person.Id > 0)
+ {
+ personInfo.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture));
+ }
+
+ yield return personInfo;
+ }
+ }
}
/// <inheritdoc />
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
index 39c0497bed..7e6b9beee9 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
@@ -48,6 +49,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
PersonKind.Producer
};
+ /// <summary>
+ /// Writing jobs to keep.
+ /// </summary>
+ private static readonly FrozenSet<string> _writerJobs = new[]
+ {
+ "writer",
+ "screenplay",
+ "novel"
+ }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
+
[GeneratedRegex(@"[\W_-[·]]+")]
private static partial Regex NonWordRegex();
@@ -82,7 +93,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
if (string.Equals(crew.Department, "writing", StringComparison.OrdinalIgnoreCase)
- && (string.Equals(crew.Job, "writer", StringComparison.OrdinalIgnoreCase) || string.Equals(crew.Job, "screenplay", StringComparison.OrdinalIgnoreCase)))
+ && crew.Job is not null && _writerJobs.Contains(crew.Job))
{
return PersonKind.Writer;
}
diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
index 02040653d1..b350f482c3 100644
--- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
@@ -78,11 +78,73 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
{
await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
+ // Note that this only updates the children's SeriesPresentationUniqueKey and SeasonId, not the ParentIndexNumber
+ if (LibraryManager.GetLibraryOptions(item).EnableAutomaticSeriesGrouping)
+ {
+ await UpdateSeriesChildrenInfoAsync(item, cancellationToken).ConfigureAwait(false);
+ }
+
RemoveObsoleteEpisodes(item);
RemoveObsoleteSeasons(item);
await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
}
+ /// <summary>
+ /// Reconciles seasons and episodes with the series' finalized state.
+ /// </summary>
+ /// <remarks>
+ /// The series' presentation unique key can change during a refresh once provider ids become
+ /// available - notably with <c>EnableAutomaticSeriesGrouping</c>, where the key is derived from
+ /// the provider id and the owning libraries instead of the (immutable) item id. Seasons and
+ /// episodes cache this value in <see cref="IHasSeries.SeriesPresentationUniqueKey"/> and are
+ /// matched to (and displayed under) the series by it, so any child left with a stale key - or an
+ /// episode not yet linked to a freshly created season - stays hidden until a later scan. Syncing
+ /// them against the series here lets everything appear within a single scan.
+ /// </remarks>
+ /// <param name="series">The series.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The async task.</returns>
+ private async Task UpdateSeriesChildrenInfoAsync(Series series, CancellationToken cancellationToken)
+ {
+ // Reload children so episode numbers / seasons persisted earlier in the refresh are seen.
+ series.Children = null;
+ var seriesKey = series.GetPresentationUniqueKey();
+ var children = series.GetRecursiveChildren(i => i is Season || i is Episode);
+ var seasons = children.OfType<Season>().ToList();
+
+ foreach (var child in children)
+ {
+ var updateType = ItemUpdateType.None;
+
+ if (child is IHasSeries hasSeries
+ && !string.Equals(hasSeries.SeriesPresentationUniqueKey, seriesKey, StringComparison.Ordinal))
+ {
+ hasSeries.SeriesPresentationUniqueKey = seriesKey;
+ updateType |= ItemUpdateType.MetadataImport;
+ }
+
+ if (child is Episode episode)
+ {
+ var seasonId = episode.FindSeasonId();
+ if (seasonId.IsEmpty() && episode.ParentIndexNumber.HasValue)
+ {
+ seasonId = seasons.Find(s => s.IndexNumber == episode.ParentIndexNumber)?.Id ?? Guid.Empty;
+ }
+
+ if (!seasonId.IsEmpty() && !episode.SeasonId.Equals(seasonId))
+ {
+ episode.SeasonId = seasonId;
+ updateType |= ItemUpdateType.MetadataImport;
+ }
+ }
+
+ if (updateType > ItemUpdateType.None)
+ {
+ await child.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
/// <inheritdoc />
protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
@@ -235,6 +297,28 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
{
var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
+
+ // CreateSeasonsAsync can run before the episodes themselves have been refreshed during an
+ // initial scan, so their ParentIndexNumber may still be unset. Resolve the season number
+ // from the path first to avoid creating a premature "Season Unknown" instead of the real
+ // season for episodes that live directly in a flat series folder.
+ foreach (var episode in seriesChildren.OfType<Episode>())
+ {
+ if (episode.ParentIndexNumber.HasValue)
+ {
+ continue;
+ }
+
+ try
+ {
+ LibraryManager.FillMissingEpisodeNumbersFromPath(episode, false);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error resolving season number from path for {Path}", episode.Path);
+ }
+ }
+
var seasons = seriesChildren.OfType<Season>().ToList();
var episodes = seriesChildren.OfType<Episode>().ToList();
diff --git a/README.md b/README.md
index 5e066f3d31..f3a5a2a4ef 100644
--- a/README.md
+++ b/README.md
@@ -7,34 +7,16 @@
<img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/>
<br/>
<br/>
-<a href="https://github.com/jellyfin/jellyfin">
-<img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/>
-</a>
-<a href="https://github.com/jellyfin/jellyfin/releases">
-<img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/>
-</a>
-<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget">
-<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/>
-</a>
-<a href="https://hub.docker.com/r/jellyfin/jellyfin">
-<img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/>
-</a>
+<a href="https://github.com/jellyfin/jellyfin"><img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/></a>
+<a href="https://github.com/jellyfin/jellyfin/releases"><img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/></a>
+<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"><img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/></a>
+<a href="https://hub.docker.com/r/jellyfin/jellyfin"><img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/></a>
<br/>
-<a href="https://opencollective.com/jellyfin">
-<img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/>
-</a>
-<a href="https://features.jellyfin.org">
-<img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/>
-</a>
-<a href="https://matrix.to/#/#jellyfinorg:matrix.org">
-<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/>
-</a>
-<a href="https://github.com/jellyfin/jellyfin/releases.atom">
-<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" />
-</a>
-<a href="https://github.com/jellyfin/jellyfin/commits/master.atom">
-<img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" />
-</a>
+<a href="https://opencollective.com/jellyfin"><img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/></a>
+<a href="https://features.jellyfin.org"><img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/></a>
+<a href="https://matrix.to/#/#jellyfinorg:matrix.org"><img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/></a>
+<a href="https://github.com/jellyfin/jellyfin/releases.atom"><img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /></a>
+<a href="https://github.com/jellyfin/jellyfin/commits/master.atom"><img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /></a>
</p>
---
diff --git a/src/Jellyfin.Extensions/PathHelper.cs b/src/Jellyfin.Extensions/PathHelper.cs
new file mode 100644
index 0000000000..ab74a7749d
--- /dev/null
+++ b/src/Jellyfin.Extensions/PathHelper.cs
@@ -0,0 +1,77 @@
+using System;
+using System.IO;
+
+namespace Jellyfin.Extensions;
+
+/// <summary>
+/// Helpers for safely composing filesystem paths from untrusted input.
+/// </summary>
+/// <remarks>
+/// <see cref="Path.Combine(string, string)"/> has two issues that matter in
+/// any code that joins a trusted directory with an externally-supplied name:
+/// it neither normalises <c>..</c> nor rejects a rooted second argument
+/// (a rooted second arg silently discards the first). Use the helpers below
+/// any time the name comes from media metadata, request input, archive
+/// entries, or any other channel that can be influenced by a third party.
+/// </remarks>
+public static class PathHelper
+{
+ /// <summary>
+ /// Reduces a possibly-untrusted file name to a safe leaf-only name with no
+ /// directory components.
+ /// </summary>
+ /// <param name="fileName">The candidate file name.</param>
+ /// <returns>
+ /// The leaf component of <paramref name="fileName"/>, or <c>null</c> if
+ /// the input has no usable leaf (empty, <c>.</c>, or <c>..</c>).
+ /// </returns>
+ public static string? GetSafeLeafFileName(string? fileName)
+ {
+ if (string.IsNullOrEmpty(fileName))
+ {
+ return null;
+ }
+
+ var leaf = Path.GetFileName(fileName);
+ if (string.IsNullOrEmpty(leaf) || string.Equals(leaf, ".", StringComparison.Ordinal) || string.Equals(leaf, "..", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ return leaf;
+ }
+
+ /// <summary>
+ /// Returns whether <paramref name="candidate"/> resolves to a path that
+ /// equals or is contained inside <paramref name="root"/>.
+ /// </summary>
+ /// <param name="root">The directory the candidate must remain inside.</param>
+ /// <param name="candidate">The candidate absolute or relative path.</param>
+ /// <returns><c>true</c> if the candidate is inside or equal to root; otherwise <c>false</c>.</returns>
+ /// <remarks>
+ /// Both arguments are resolved via <see cref="Path.GetFullPath(string)"/>
+ /// so <c>..</c> segments are collapsed before the comparison. The root is
+ /// compared with a trailing directory separator to prevent prefix
+ /// collisions (e.g. <c>/var/data</c> must not be accepted as a parent of
+ /// <c>/var/dataset</c>).
+ /// </remarks>
+ public static bool IsContainedIn(string root, string candidate)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(root);
+ ArgumentException.ThrowIfNullOrEmpty(candidate);
+
+ var fullRoot = Path.GetFullPath(root);
+ var fullCandidate = Path.GetFullPath(candidate);
+
+ if (string.Equals(fullCandidate, fullRoot, StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ var rootWithSep = fullRoot.EndsWith(Path.DirectorySeparatorChar)
+ ? fullRoot
+ : fullRoot + Path.DirectorySeparatorChar;
+
+ return fullCandidate.StartsWith(rootWithSep, StringComparison.Ordinal);
+ }
+}
diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
index ed02fe6a1d..e421601092 100644
--- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
+++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
@@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
+using Jellyfin.LiveTv;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
@@ -1109,9 +1110,8 @@ namespace Jellyfin.LiveTv.Channels
item.Path = mediaSource?.Path;
}
- if (!string.IsNullOrEmpty(info.ImageUrl) && !item.HasImage(ImageType.Primary))
+ if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, null, info.ImageUrl))
{
- item.SetImagePath(ImageType.Primary, info.ImageUrl);
_logger.LogDebug("Forcing update due to ImageUrl {0}", item.Name);
forceUpdate = true;
}
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index c3cc70381e..4e1b62cdf9 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -5,7 +5,9 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
+using Jellyfin.LiveTv;
using Jellyfin.LiveTv.Configuration;
+using Jellyfin.LiveTv.Listings;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -448,23 +450,9 @@ public class GuideManager : IGuideManager
item.Name = channelInfo.Name;
- var currentPrimary = item.GetImageInfo(ImageType.Primary, 0);
- var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl);
-
- // Update channel image if image URL has changed
- if (currentPrimary is null
- || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal)))
+ if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl))
{
- if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
- {
- item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
- forceUpdate = true;
- }
- else if (!imageUrlIsNull)
- {
- item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
- forceUpdate = true;
- }
+ forceUpdate = true;
}
if (isNew)
@@ -499,8 +487,13 @@ public class GuideManager : IGuideManager
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
-
- item.TrySetProviderId(EtagKey, info.Etag);
+ }
+ else if (XmlTvProgramEtag.MatchesStored(info.Etag, item.GetProviderId(EtagKey)))
+ {
+ // XMLTV ETags are generated from the final ProgramInfo fields Jellyfin consumes,
+ // so an exact match means nothing relevant changed. Other providers stay on the
+ // field-by-field update path.
+ return (item, false, false);
}
if (!string.Equals(info.ShowId, item.ShowId, StringComparison.OrdinalIgnoreCase))
@@ -626,13 +619,9 @@ public class GuideManager : IGuideManager
forceUpdate |= UpdateImages(item, info);
- if (isNew)
- {
- item.OnMetadataChanged();
-
- return (item, true, false);
- }
-
+ // Restore the etag wiped by `item.ProviderIds = info.ProviderIds` above and
+ // persist it on new items so they join the fast path on the next refresh
+ // instead of taking an extra full processing cycle.
var isUpdated = forceUpdate;
var etag = info.Etag;
if (string.IsNullOrWhiteSpace(etag))
@@ -645,6 +634,13 @@ public class GuideManager : IGuideManager
isUpdated = true;
}
+ if (isNew)
+ {
+ item.OnMetadataChanged();
+
+ return (item, true, false);
+ }
+
if (isUpdated)
{
item.OnMetadataChanged();
diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
index d877a0d124..19c4514766 100644
--- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
@@ -83,6 +83,7 @@ namespace Jellyfin.LiveTv.IO
CreateNoWindow = true,
UseShellExecute = false,
+ StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true,
RedirectStandardInput = true,
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
index c1ccb24bf4..c93d1f039c 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
@@ -491,6 +491,12 @@ namespace Jellyfin.LiveTv.Listings
var results = new List<ShowImagesDto>();
for (int i = 0; i < programIds.Count; i += BatchSize)
{
+ // The daily image limit may be surfaced mid-batch.
+ if (IsImageDailyLimitActive())
+ {
+ break;
+ }
+
var batch = programIds.Skip(i).Take(BatchSize);
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
@@ -511,6 +517,18 @@ namespace Jellyfin.LiveTv.Listings
entry.ProgramId,
entry.Code,
entry.Message);
+
+ // The image download limit can be reported per-entry inside an
+ // otherwise successful (HTTP 200) response when the limit is hit
+ // mid-batch. Back off so we stop requesting images until SD resets.
+ if (entry.Code is (int)SdErrorCode.MaxImageDownloads or (int)SdErrorCode.MaxImageDownloadsTrial)
+ {
+ _logger.LogError(
+ "Schedules Direct image download limit hit (code {Code}). Disabling image acquisition until SD reset.",
+ entry.Code);
+ SetImageLimitHit();
+ }
+
continue;
}
@@ -748,9 +766,7 @@ namespace Jellyfin.LiveTv.Listings
#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
#pragma warning restore CA5350
- // TODO: remove ToLower when Convert.ToHexString supports lowercase
- // Schedules Direct requires the hex to be lowercase
- string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
+ string hashedPassword = Convert.ToHexStringLower(hashedPasswordBytes);
options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
index 7088d1f0bf..0aeb7ad05d 100644
--- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
@@ -173,7 +173,29 @@ namespace Jellyfin.LiveTv.Listings
var reader = new XmlTvReader(path, GetLanguage(info));
return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken)
- .Select(p => GetProgramInfo(p, info));
+ .Select(p => GetProgramInfoWithEtag(p, info));
+ }
+
+ private ProgramInfo GetProgramInfoWithEtag(XmlTvProgram program, ListingsProviderInfo info)
+ {
+ var programInfo = GetProgramInfo(program, info);
+
+ if (XmlTvProgramEtag.TryCreate(programInfo, out var etag, out var reason))
+ {
+ programInfo.Etag = etag;
+ }
+ else
+ {
+ _logger.LogDebug(
+ "Unable to create XMLTV program ETag for program {ProgramId} on channel {ChannelId} from {StartDate} to {EndDate}: {Reason}. The program will be treated as updated on each guide refresh.",
+ programInfo.Id,
+ programInfo.ChannelId,
+ programInfo.StartDate,
+ programInfo.EndDate,
+ reason);
+ }
+
+ return programInfo;
}
private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info)
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs
new file mode 100644
index 0000000000..b5ddb1530f
--- /dev/null
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvProgramEtag.cs
@@ -0,0 +1,184 @@
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using MediaBrowser.Controller.LiveTv;
+
+namespace Jellyfin.LiveTv.Listings
+{
+ internal static class XmlTvProgramEtag
+ {
+ internal const string Prefix = "xmltv-sha256-v1:";
+
+ internal static bool IsXmlTvEtag(string? etag)
+ => !string.IsNullOrWhiteSpace(etag)
+ && etag.StartsWith(Prefix, StringComparison.Ordinal);
+
+ // Returns true only when the incoming etag is XMLTV-style AND equals the stored value.
+ // The IsXmlTvEtag gate keeps other providers (e.g. Schedules Direct) on the
+ // field-by-field update path even if their etag strings happen to match.
+ internal static bool MatchesStored(string? incomingEtag, string? storedEtag)
+ => IsXmlTvEtag(incomingEtag)
+ && string.Equals(incomingEtag, storedEtag, StringComparison.OrdinalIgnoreCase);
+
+ internal static bool TryCreate(ProgramInfo programInfo, out string? etag, out string? reason)
+ {
+ etag = null;
+
+ if (string.IsNullOrWhiteSpace(programInfo.Id))
+ {
+ reason = "program id is empty";
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(programInfo.ChannelId))
+ {
+ reason = "channel id is empty";
+ return false;
+ }
+
+ if (programInfo.StartDate == default)
+ {
+ reason = "start date is empty";
+ return false;
+ }
+
+ if (programInfo.EndDate == default)
+ {
+ reason = "end date is empty";
+ return false;
+ }
+
+ if (programInfo.EndDate <= programInfo.StartDate)
+ {
+ reason = "end date is not after start date";
+ return false;
+ }
+
+ var builder = new StringBuilder(1024);
+
+ // Keep this list aligned with the ProgramInfo fields consumed by GuideManager.
+ AppendValue(builder, "schema", "xmltv-programinfo-v1");
+ AppendValue(builder, nameof(programInfo.Id), programInfo.Id);
+ AppendValue(builder, nameof(programInfo.ChannelId), programInfo.ChannelId);
+ AppendValue(builder, nameof(programInfo.Name), programInfo.Name);
+ AppendValue(builder, nameof(programInfo.OfficialRating), programInfo.OfficialRating);
+ AppendValue(builder, nameof(programInfo.Overview), programInfo.Overview);
+ AppendValue(builder, nameof(programInfo.StartDate), programInfo.StartDate);
+ AppendValue(builder, nameof(programInfo.EndDate), programInfo.EndDate);
+ AppendList(builder, nameof(programInfo.Genres), programInfo.Genres);
+ AppendValue(builder, nameof(programInfo.OriginalAirDate), programInfo.OriginalAirDate);
+ AppendValue(builder, nameof(programInfo.IsHD), programInfo.IsHD);
+ AppendValue(builder, nameof(programInfo.Audio), programInfo.Audio?.ToString());
+ AppendValue(builder, nameof(programInfo.CommunityRating), programInfo.CommunityRating);
+ AppendValue(builder, nameof(programInfo.IsRepeat), programInfo.IsRepeat);
+ AppendValue(builder, nameof(programInfo.EpisodeTitle), programInfo.EpisodeTitle);
+ AppendValue(builder, nameof(programInfo.ImagePath), programInfo.ImagePath);
+ AppendValue(builder, nameof(programInfo.ImageUrl), programInfo.ImageUrl);
+ AppendValue(builder, nameof(programInfo.ThumbImageUrl), programInfo.ThumbImageUrl);
+ AppendValue(builder, nameof(programInfo.LogoImageUrl), programInfo.LogoImageUrl);
+ AppendValue(builder, nameof(programInfo.BackdropImageUrl), programInfo.BackdropImageUrl);
+ AppendValue(builder, nameof(programInfo.IsMovie), programInfo.IsMovie);
+ AppendValue(builder, nameof(programInfo.IsSports), programInfo.IsSports);
+ AppendValue(builder, nameof(programInfo.IsSeries), programInfo.IsSeries);
+ AppendValue(builder, nameof(programInfo.IsLive), programInfo.IsLive);
+ AppendValue(builder, nameof(programInfo.IsNews), programInfo.IsNews);
+ AppendValue(builder, nameof(programInfo.IsKids), programInfo.IsKids);
+ AppendValue(builder, nameof(programInfo.IsPremiere), programInfo.IsPremiere);
+ AppendValue(builder, nameof(programInfo.ProductionYear), programInfo.ProductionYear);
+ AppendValue(builder, nameof(programInfo.SeriesId), programInfo.SeriesId);
+ AppendValue(builder, nameof(programInfo.ShowId), programInfo.ShowId);
+ AppendValue(builder, nameof(programInfo.SeasonNumber), programInfo.SeasonNumber);
+ AppendValue(builder, nameof(programInfo.EpisodeNumber), programInfo.EpisodeNumber);
+ AppendDictionary(builder, nameof(programInfo.ProviderIds), programInfo.ProviderIds);
+ AppendDictionary(builder, nameof(programInfo.SeriesProviderIds), programInfo.SeriesProviderIds);
+
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
+ etag = Prefix + Convert.ToHexString(hash);
+ reason = null;
+ return true;
+ }
+
+ private static void AppendValue(StringBuilder builder, string name, string? value)
+ {
+ builder.Append(name).Append('|');
+ if (value is null)
+ {
+ builder.Append('N').Append("|0|");
+ }
+ else
+ {
+ builder.Append('S')
+ .Append('|')
+ .Append(value.Length.ToString(CultureInfo.InvariantCulture))
+ .Append('|')
+ .Append(value);
+ }
+
+ builder.Append('\n');
+ }
+
+ private static void AppendValue(StringBuilder builder, string name, DateTime value)
+ => AppendValue(builder, name, FormatDateTime(value));
+
+ private static void AppendValue(StringBuilder builder, string name, DateTime? value)
+ => AppendValue(builder, name, value.HasValue ? FormatDateTime(value.Value) : null);
+
+ private static void AppendValue(StringBuilder builder, string name, bool value)
+ => AppendValue(builder, name, value ? "true" : "false");
+
+ private static void AppendValue(StringBuilder builder, string name, bool? value)
+ => AppendValue(builder, name, value switch { true => "true", false => "false", null => null });
+
+ private static void AppendValue(StringBuilder builder, string name, int? value)
+ => AppendValue(builder, name, value?.ToString(CultureInfo.InvariantCulture));
+
+ private static void AppendValue(StringBuilder builder, string name, float? value)
+ => AppendValue(builder, name, value?.ToString("R", CultureInfo.InvariantCulture));
+
+ // Treat Unspecified as UTC so the etag does not vary with the server's local timezone.
+ private static string FormatDateTime(DateTime value)
+ {
+ var utc = value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Unspecified => DateTime.SpecifyKind(value, DateTimeKind.Utc),
+ _ => value.ToUniversalTime(),
+ };
+
+ return utc.ToString("O", CultureInfo.InvariantCulture);
+ }
+
+ private static void AppendList(StringBuilder builder, string name, IReadOnlyList<string> values)
+ {
+ AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture));
+ for (var i = 0; i < values.Count; i++)
+ {
+ AppendValue(builder, $"{name}[{i}]", values[i]);
+ }
+ }
+
+ private static void AppendDictionary(StringBuilder builder, string name, IReadOnlyDictionary<string, string?> values)
+ {
+ AppendValue(builder, name + ".Count", values.Count.ToString(CultureInfo.InvariantCulture));
+ if (values.Count == 0)
+ {
+ return;
+ }
+
+ var index = 0;
+ foreach (var (key, value) in values
+ .OrderBy(i => i.Key, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(i => i.Key, StringComparer.Ordinal))
+ {
+ AppendValue(builder, $"{name}[{index}].Key", key);
+ AppendValue(builder, $"{name}[{index}].Value", value);
+ index++;
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs
new file mode 100644
index 0000000000..a590193b5f
--- /dev/null
+++ b/src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs
@@ -0,0 +1,33 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+
+namespace Jellyfin.LiveTv;
+
+/// <summary>
+/// Helpers for keeping Live TV channel icons in sync with guide data.
+/// </summary>
+internal static class LiveTvChannelImageHelper
+{
+ /// <summary>
+ /// Applies the channel icon from guide or tuner metadata.
+ /// Called on each guide refresh so remote icons are re-downloaded even when the URL is unchanged.
+ /// </summary>
+ /// <param name="item">The channel item.</param>
+ /// <param name="imagePath">The local image path from the tuner, if any.</param>
+ /// <param name="imageUrl">The remote image URL from the guide provider, if any.</param>
+ /// <returns><c>true</c> when the item image metadata was updated.</returns>
+ internal static bool UpdateChannelImageIfNeeded(BaseItem item, string? imagePath, string? imageUrl)
+ {
+ var newImageSource = !string.IsNullOrWhiteSpace(imagePath)
+ ? imagePath
+ : imageUrl;
+
+ if (string.IsNullOrWhiteSpace(newImageSource))
+ {
+ return false;
+ }
+
+ item.SetImagePath(ImageType.Primary, newImageSource);
+ return true;
+ }
+}
diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
index cbe97a8210..af868e4bd6 100644
--- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
+++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
+using System.Text;
namespace Jellyfin.MediaEncoding.Keyframes.FfProbe;
@@ -31,6 +32,7 @@ public static class FfProbeKeyframeExtractor
CreateNoWindow = true,
UseShellExecute = false,
+ StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden,
diff --git a/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs
new file mode 100644
index 0000000000..a248664928
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/HlsSegmentControllerTests.cs
@@ -0,0 +1,161 @@
+using System;
+using System.IO;
+using Jellyfin.Api.Controllers;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.IO;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+// The legacy HLS endpoints build a file path from caller-supplied route values, and the audio
+// and video segment endpoints are not authenticated. These tests pin down that requests escaping
+// the transcode directory are rejected while legitimate ones still serve a file.
+public sealed class HlsSegmentControllerTests
+{
+ private readonly Mock<IFileSystem> _fileSystem = new();
+ private readonly Mock<IServerConfigurationManager> _config = new();
+ private readonly Mock<ITranscodeManager> _transcodeManager = new();
+ private readonly string _transcodePath;
+
+ public HlsSegmentControllerTests()
+ {
+ _transcodePath = Path.Combine(Path.GetTempPath(), "jellyfin-hls-segment-tests");
+ Directory.CreateDirectory(_transcodePath);
+
+ _config.Setup(c => c.GetConfiguration("encoding"))
+ .Returns(new EncodingOptions { TranscodingTempPath = _transcodePath });
+ _config.SetupGet(c => c.CommonApplicationPaths).Returns(Mock.Of<IApplicationPaths>());
+ }
+
+ private HlsSegmentController CreateController(string requestPath)
+ {
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Path = requestPath;
+
+ return new HlsSegmentController(_fileSystem.Object, _config.Object, _transcodeManager.Object)
+ {
+ ControllerContext = new ControllerContext { HttpContext = httpContext }
+ };
+ }
+
+ [Fact]
+ public void GetHlsAudioSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ var result = controller.GetHlsAudioSegmentLegacy("abc", "segment");
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ [InlineData("subdir/../../../../etc/passwd")]
+ public void GetHlsAudioSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ var result = controller.GetHlsAudioSegmentLegacy("abc", segmentId);
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsAudioSegmentLegacy_AbsoluteRootedPath_ReturnsBadRequest()
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ // A rooted segment id makes Path.GetFullPath discard the transcode base.
+ var rooted = OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd";
+ var result = controller.GetHlsAudioSegmentLegacy("abc", rooted);
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsAudioSegmentLegacy_SiblingPrefixDirectory_ReturnsBadRequest()
+ {
+ var controller = CreateController("/Audio/abc/hls/segment/stream.mp3");
+
+ // Resolves to "<transcodePath>-evil/passwd", which shares the transcode path as a string prefix.
+ var result = controller.GetHlsAudioSegmentLegacy("abc", "../jellyfin-hls-segment-tests-evil/passwd");
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsPlaylistLegacy_M3u8InsideTranscodePath_ReturnsFile()
+ {
+ var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
+
+ var result = controller.GetHlsPlaylistLegacy("abc", "list");
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsPlaylistLegacy_NonPlaylistExtension_ReturnsBadRequest()
+ {
+ // Playlist endpoint serves only .m3u8, even for a path inside the transcode dir.
+ var controller = CreateController("/Videos/abc/hls/list/stream.mp4");
+
+ var result = controller.GetHlsPlaylistLegacy("abc", "list");
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ public void GetHlsPlaylistLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string playlistId)
+ {
+ var controller = CreateController("/Videos/abc/hls/list/stream.m3u8");
+
+ var result = controller.GetHlsPlaylistLegacy("abc", playlistId);
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsVideoSegmentLegacy_SegmentInsideTranscodePath_ReturnsFile()
+ {
+ _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
+ .Returns(new[] { Path.Combine(_transcodePath, "playlist123.ts") });
+
+ var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
+
+ var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Fact]
+ public void GetHlsVideoSegmentLegacy_NoMatchingPlaylist_ReturnsNotFound()
+ {
+ _fileSystem.Setup(f => f.GetFilePaths(_transcodePath, false))
+ .Returns(Array.Empty<string>());
+
+ var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
+
+ var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", "seg1", "ts");
+
+ Assert.IsType<NotFoundObjectResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ public void GetHlsVideoSegmentLegacy_TraversalOutsideTranscodePath_ReturnsBadRequest(string segmentId)
+ {
+ var controller = CreateController("/Videos/abc/hls/playlist123/seg1.ts");
+
+ var result = controller.GetHlsVideoSegmentLegacy("abc", "playlist123", segmentId, "ts");
+
+ Assert.IsType<BadRequestObjectResult>(result);
+ _fileSystem.Verify(f => f.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
+ }
+}
diff --git a/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
new file mode 100644
index 0000000000..f040a328bb
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Controllers/PluginsControllerTests.cs
@@ -0,0 +1,129 @@
+using System;
+using System.IO;
+using Jellyfin.Api.Controllers;
+using MediaBrowser.Common.Plugins;
+using MediaBrowser.Common.Updates;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Controllers;
+
+// Covers the path-traversal validation in GetPluginImage: a plugin's manifest ImagePath
+// must resolve to a file inside the plugin's own directory.
+public sealed class PluginsControllerTests
+{
+ private readonly Mock<IPluginManager> _pluginManager = new();
+ private readonly string _pluginPath;
+
+ public PluginsControllerTests()
+ {
+ _pluginPath = Path.Combine(Path.GetTempPath(), "jellyfin-plugin-image-tests");
+ Directory.CreateDirectory(_pluginPath);
+ }
+
+ private PluginsController CreateController() =>
+ new PluginsController(Mock.Of<IInstallationManager>(), _pluginManager.Object)
+ {
+ ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
+ };
+
+ private void SetupPlugin(Guid id, Version version, string? imagePath)
+ {
+ var manifest = new PluginManifest { Id = id, Name = "Test", Version = version.ToString(), ImagePath = imagePath };
+ _pluginManager.Setup(p => p.GetPlugin(id, version))
+ .Returns(new LocalPlugin(_pluginPath, true, manifest));
+ }
+
+ [Fact]
+ public void GetPluginImage_UnknownPlugin_ReturnsNotFound()
+ {
+ var result = CreateController().GetPluginImage(Guid.NewGuid(), new Version(1, 0));
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_ImageInsidePluginPath_ReturnsFile()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ File.WriteAllBytes(Path.Combine(_pluginPath, "logo.png"), Array.Empty<byte>());
+ SetupPlugin(id, version, "logo.png");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<PhysicalFileResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_ImageInsidePluginPathButMissing_ReturnsNotFound()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, "does-not-exist.png");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Theory]
+ [InlineData("../../../../etc/passwd")]
+ [InlineData("subdir/../../../../etc/passwd")]
+ public void GetPluginImage_TraversalOutsidePluginPath_ReturnsNotFound(string imagePath)
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, imagePath);
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_SiblingPrefixDirectory_ReturnsNotFound()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ // Resolves to "<pluginPath>-evil/logo.png", which shares the plugin path as a string prefix.
+ // The file is created so the check fails on the boundary, not on File.Exists.
+ var siblingDir = _pluginPath + "-evil";
+ Directory.CreateDirectory(siblingDir);
+ File.WriteAllBytes(Path.Combine(siblingDir, "logo.png"), Array.Empty<byte>());
+ SetupPlugin(id, version, "../jellyfin-plugin-image-tests-evil/logo.png");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Fact]
+ public void GetPluginImage_AbsoluteImagePath_ReturnsNotFound()
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, OperatingSystem.IsWindows() ? "C:\\Windows\\win.ini" : "/etc/passwd");
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void GetPluginImage_NoImagePathOrResource_ReturnsNotFound(string? imagePath)
+ {
+ var id = Guid.NewGuid();
+ var version = new Version(1, 0);
+ SetupPlugin(id, version, imagePath);
+
+ var result = CreateController().GetPluginImage(id, version);
+
+ Assert.IsType<NotFoundResult>(result);
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
new file mode 100644
index 0000000000..5132e529dd
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/ClientEventLoggerTests.cs
@@ -0,0 +1,44 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.ClientEvent;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests
+{
+ public class ClientEventLoggerTests
+ {
+ [Theory]
+ [InlineData("../../../../etc/passwd", "1.0")]
+ [InlineData("..\\..\\windows\\system32", "1.0")]
+ [InlineData("normal-client", "../../../etc/passwd")]
+ [InlineData("/absolute/path", "1.0")]
+ public async Task WriteDocumentAsync_TraversalInput_StaysInsideLogDirectory(string clientName, string clientVersion)
+ {
+ var logDir = Path.Combine(Path.GetTempPath(), "jellyfin-clientlog-test-" + Path.GetRandomFileName());
+ Directory.CreateDirectory(logDir);
+ try
+ {
+ var paths = new Mock<IServerApplicationPaths>();
+ paths.Setup(p => p.LogDirectoryPath).Returns(logDir);
+
+ var logger = new ClientEventLogger(paths.Object);
+ using var contents = new MemoryStream(Encoding.UTF8.GetBytes("payload"));
+
+ var fileName = await logger.WriteDocumentAsync(clientName, clientVersion, contents);
+
+ var resolved = Path.GetFullPath(Path.Combine(logDir, fileName));
+ var rootWithSep = Path.GetFullPath(logDir) + Path.DirectorySeparatorChar;
+ Assert.StartsWith(rootWithSep, resolved, StringComparison.Ordinal);
+ Assert.True(File.Exists(resolved));
+ }
+ finally
+ {
+ Directory.Delete(logDir, recursive: true);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 5c187da413..c0a2b0ecca 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -1,6 +1,15 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Moq;
using Xunit;
@@ -46,4 +55,284 @@ public class BaseItemTests
Assert.Equal(name, video.GetMediaSourceName(video));
Assert.Equal(altName, video.GetMediaSourceName(videoAlt));
}
+
+ [Theory]
+ // Episode versions share a season folder; the common prefix (not the folder name) yields the label.
+ // Both files carry a suffix (no bare base name), so the shared "- " must be stripped too.
+ [InlineData(
+ "Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
+ "Spider-Noir - S01E02 - Wo ist Flint - Colorized",
+ "Greyscale",
+ "Colorized")]
+ // One version is the bare base name; the other is suffixed.
+ [InlineData(
+ "Spider-Noir - S01E02 - Wo ist Flint",
+ "Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
+ "Spider-Noir - S01E02 - Wo ist Flint",
+ "Greyscale")]
+ // Suffixes share a leading word ("Grey"); the prefix must retreat to the separator, not split it.
+ [InlineData(
+ "Demo - S01E01 - Greyscale",
+ "Demo - S01E01 - Greyish",
+ "Greyscale",
+ "Greyish")]
+ // Underscore separator.
+ [InlineData("Movie (2020)_4K", "Movie (2020)_1080p", "4K", "1080p")]
+ // Dot separator.
+ [InlineData("Movie (2020).UHD", "Movie (2020).1080p", "UHD", "1080p")]
+ // Resolution variants that share leading digits must retreat to the separator, not yield "p"/"i".
+ [InlineData("Movie - 1080p", "Movie - 1080i", "1080p", "1080i")]
+ // A token shared by the descriptors but separated only by spaces (the resolution) must stay in the
+ // label: retreat to the '-' delimiter, not the interior space, so the resolution is kept.
+ [InlineData(
+ "movie (2020) - 2160p Extended",
+ "movie (2020) - 2160p Original",
+ "2160p Extended",
+ "2160p Original")]
+ // Bracketed version labels: the opening bracket is kept in the label.
+ [InlineData(
+ "Blade Runner (1982) [Final Cut] [1080p HEVC AAC]",
+ "Blade Runner (1982) [EE by ADM] [480p HEVC AAC]",
+ "[Final Cut] [1080p HEVC AAC]",
+ "[EE by ADM] [480p HEVC AAC]")]
+ public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt)
+ {
+ var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv";
+ var altPath = "/Shows/Demo/Season 01/" + altName + ".mkv";
+ var commonPrefix = BaseItem.GetCommonVersionPrefix([primaryName, altName]);
+
+ var video = new Video()
+ {
+ Path = primaryPath
+ };
+
+ var videoAlt = new Video()
+ {
+ Path = altPath,
+ };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>()))
+ .Returns((string x) => MediaProtocol.File);
+ var libraryManager = new Mock<ILibraryManager>();
+ // No local alternate versions: these are linked (separate items), so the folder fallback is unavailable.
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()))
+ .Returns(Array.Empty<Guid>());
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ Assert.Equal(expectedPrimary, video.GetMediaSourceName(video, commonPrefix));
+ Assert.Equal(expectedAlt, videoAlt.GetMediaSourceName(videoAlt, commonPrefix));
+ }
+
+ [Fact]
+ public void GetAlternateVersion_ReturnsMatchingLocalVersion()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ Assert.Same(alt1, primary.GetAlternateVersion(alt1.Id));
+ Assert.Same(alt2, primary.GetAlternateVersion(alt2.Id));
+ Assert.Same(primary, primary.GetAlternateVersion(primary.Id));
+ Assert.Null(primary.GetAlternateVersion(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void GetAllVersions_FromAnyVersion_ReturnsEveryVersionOnce()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ foreach (var source in new[] { primary, alt1, alt2 })
+ {
+ var versions = source.GetAllVersions();
+
+ Assert.Equal(3, versions.Count);
+ Assert.Contains(versions, v => v.Id.Equals(primary.Id));
+ Assert.Contains(versions, v => v.Id.Equals(alt1.Id));
+ Assert.Contains(versions, v => v.Id.Equals(alt2.Id));
+ }
+ }
+
+ [Fact]
+ public void PropagatePlayedState_MarksAlternateVersions_AndResetsPositionByDefault()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var saved = CaptureSaves();
+
+ var user = new User("test", "default", "default");
+ primary.PropagatePlayedState(user, true);
+
+ // Both alternate versions are marked played, the primary (self) is not, and the position is
+ // reset so a watched version does not linger in "Continue Watching".
+ Assert.Equal(2, saved.Count);
+ Assert.DoesNotContain(saved, e => e.ItemId.Equals(primary.Id));
+ Assert.Contains(saved, e => e.ItemId.Equals(alt1.Id));
+ Assert.Contains(saved, e => e.ItemId.Equals(alt2.Id));
+ Assert.All(saved, e =>
+ {
+ Assert.True(e.Dto.Played.GetValueOrDefault());
+ Assert.Equal(0, e.Dto.PlaybackPositionTicks);
+ });
+ }
+
+ [Fact]
+ public void PropagatePlayedState_WithoutReset_LeavesPositionUntouched()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+
+ var saved = CaptureSaves();
+
+ primary.PropagatePlayedState(new User("test", "default", "default"), true, resetPosition: false);
+
+ Assert.Equal(2, saved.Count);
+ Assert.All(saved, e =>
+ {
+ Assert.True(e.Dto.Played.GetValueOrDefault());
+ Assert.Null(e.Dto.PlaybackPositionTicks);
+ });
+ }
+
+ [Fact]
+ public void PropagatePlayedState_Unwatched_ClearsAllWatchedStateOnVersions()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ // Each alternate starts out watched, with a play count, resume point and last-played date.
+ var existing = new Dictionary<Guid, UserItemData>
+ {
+ [alt1.Id] = new UserItemData { Key = "alt1", Played = true, PlayCount = 3, PlaybackPositionTicks = 1000, LastPlayedDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
+ [alt2.Id] = new UserItemData { Key = "alt2", Played = true, PlayCount = 1, PlaybackPositionTicks = 500, LastPlayedDate = new DateTime(2021, 2, 2, 0, 0, 0, DateTimeKind.Utc) },
+ };
+
+ var saved = new List<UserItemData>();
+ var userDataManager = new Mock<IUserDataManager>();
+ userDataManager.Setup(x => x.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
+ .Returns((User _, BaseItem item) => existing.GetValueOrDefault(item.Id));
+ userDataManager
+ .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), It.IsAny<UserDataSaveReason>(), It.IsAny<CancellationToken>()))
+ .Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>((_, _, data, _, _) => saved.Add(data));
+ BaseItem.UserDataManager = userDataManager.Object;
+
+ primary.PropagatePlayedState(new User("test", "default", "default"), false);
+
+ // Every alternate is fully reset to an unwatched state, mirroring MarkUnplayed: the played flag,
+ // play count, resume point and last-played date are all cleared so no watched state lingers.
+ Assert.Equal(2, saved.Count);
+ Assert.All(saved, d =>
+ {
+ Assert.False(d.Played);
+ Assert.Equal(0, d.PlayCount);
+ Assert.Equal(0, d.PlaybackPositionTicks);
+ Assert.Null(d.LastPlayedDate);
+ });
+ }
+
+ private static List<(Guid ItemId, UpdateUserItemDataDto Dto)> CaptureSaves()
+ {
+ var saved = new List<(Guid ItemId, UpdateUserItemDataDto Dto)>();
+ var userDataManager = new Mock<IUserDataManager>();
+ userDataManager
+ .Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()))
+ .Callback<User, BaseItem, UpdateUserItemDataDto, UserDataSaveReason>((_, item, dto, _) => saved.Add((item.Id, dto)));
+ BaseItem.UserDataManager = userDataManager.Object;
+ return saved;
+ }
+
+ [Fact]
+ public void PropagatePlayedState_SingleVersion_DoesNothing()
+ {
+ var solo = new Video { Id = Guid.NewGuid(), Path = "/Movies/Solo/Solo.mkv" };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var userDataManager = new Mock<IUserDataManager>();
+ BaseItem.UserDataManager = userDataManager.Object;
+
+ solo.PropagatePlayedState(new User("test", "default", "default"), true);
+
+ userDataManager.Verify(
+ x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()),
+ Times.Never);
+ }
+
+ private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
+ {
+ var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
+ var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
+ var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
+
+ // 2160p primary, 1080p alternates: width is only the ordering tiebreaker, set so it would place
+ // the primary first — letting the tests confirm the queried version's own source still wins.
+ var widths = new Dictionary<Guid, int> { [primary.Id] = 3840, [alt1.Id] = 1920, [alt2.Id] = 1920 };
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>()))
+ .Returns((Guid id) => new List<MediaStream> { new MediaStream { Type = MediaStreamType.Video, Width = widths.GetValueOrDefault(id) } });
+ mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>());
+
+ var segmentManager = new Mock<IMediaSegmentManager>();
+ segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false);
+ BaseItem.MediaSegmentManager = segmentManager.Object;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
+ libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
+ libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
+
+ var recordingsManager = new Mock<IRecordingsManager>();
+ recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null);
+ Video.RecordingsManager = recordingsManager.Object;
+
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ return (primary, alt1, alt2);
+ }
+
+ [Fact]
+ public void GetMediaSources_DefaultsToTheQueriedVersionsOwnSource()
+ {
+ var (primary, alt1, _) = SetupVersionGroup();
+
+ // Resuming the 1080p alternate must default to the 1080p source, not the higher-resolution
+ // 2160p primary that the width ordering would otherwise place first.
+ Assert.Equal(alt1.Id.ToString("N"), alt1.GetMediaSources(false)[0].Id);
+
+ // Opening the primary still defaults to the primary's own (here highest-resolution) source.
+ Assert.Equal(primary.Id.ToString("N"), primary.GetMediaSources(false)[0].Id);
+ }
+
+ [Fact]
+ public void GetAllItemsForMediaSources_FromAnyVersion_HasNoDuplicates()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var method = typeof(Video).GetMethod("GetAllItemsForMediaSources", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+
+ // Each version must surface exactly once, regardless of which member the list is built from.
+ // Building from an alternate previously re-added that alternate as a "local alternate" of the
+ // primary, producing a duplicate entry in the version dropdown.
+ foreach (var source in new[] { primary, alt1, alt2 })
+ {
+ var items = (IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)>)method!.Invoke(source, null)!;
+ var ids = items.Select(i => i.Item.Id).ToList();
+
+ Assert.Equal(3, ids.Count);
+ Assert.Equal(ids.Count, ids.Distinct().Count());
+ Assert.Contains(primary.Id, ids);
+ Assert.Contains(alt1.Id, ids);
+ Assert.Contains(alt2.Id, ids);
+ }
+ }
}
diff --git a/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
new file mode 100644
index 0000000000..7d87d5ee92
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Library/VersionResumeDataTests.cs
@@ -0,0 +1,89 @@
+using System;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Library;
+
+public class VersionResumeDataTests
+{
+ [Fact]
+ public void ApplyTo_CompletedOtherVersion_PropagatesCompletionAndClearsStaleResume()
+ {
+ var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ var resume = new VersionResumeData(
+ Guid.NewGuid(),
+ new UserItemData { Key = "version", PlaybackPositionTicks = 0, Played = true, LastPlayedDate = lastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 };
+
+ resume.ApplyTo(dto);
+
+ // Completion state propagates to the primary...
+ Assert.True(dto.Played);
+ Assert.Equal(lastPlayed, dto.LastPlayedDate);
+
+ // ...and because the movie was finished on a different version, the primary's own stale resume bar is cleared.
+ Assert.Equal(0, dto.PlaybackPositionTicks);
+ Assert.Null(dto.PlayedPercentage);
+ }
+
+ [Fact]
+ public void ApplyTo_PrimaryOwnProgress_KeepsResumePosition()
+ {
+ var primaryId = Guid.NewGuid();
+ var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+
+ // The winning version is the primary itself (e.g. rewatching): its resume bar must survive.
+ var resume = new VersionResumeData(
+ primaryId,
+ new UserItemData { Key = "primary", PlaybackPositionTicks = 5, Played = true, LastPlayedDate = lastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = primaryId, Key = "primary", PlaybackPositionTicks = 5, Played = true, PlayedPercentage = 20 };
+
+ resume.ApplyTo(dto);
+
+ Assert.True(dto.Played);
+ Assert.Equal(5, dto.PlaybackPositionTicks);
+ Assert.Equal(20, dto.PlayedPercentage);
+ }
+
+ [Fact]
+ public void ApplyTo_InProgressOtherVersion_KeepsPrimaryResumePosition()
+ {
+ var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+
+ // A different version that is in-progress (not finished) must not clear the primary's position.
+ var resume = new VersionResumeData(
+ Guid.NewGuid(),
+ new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = false, LastPlayedDate = lastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 };
+
+ resume.ApplyTo(dto);
+
+ Assert.False(dto.Played);
+ Assert.Equal(1, dto.PlaybackPositionTicks);
+ Assert.Equal(50, dto.PlayedPercentage);
+ }
+
+ [Fact]
+ public void ApplyTo_DoesNotUnsetExistingPlayedOrRegressLastPlayed()
+ {
+ var primaryLastPlayed = new DateTime(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc);
+ var versionLastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ var resume = new VersionResumeData(
+ Guid.NewGuid(),
+ new UserItemData { Key = "version", Played = false, LastPlayedDate = versionLastPlayed });
+
+ var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed };
+
+ resume.ApplyTo(dto);
+
+ // A not-yet-completed version must not clear the primary's own completion, and the more recent
+ // LastPlayedDate is kept.
+ Assert.True(dto.Played);
+ Assert.Equal(primaryLastPlayed, dto.LastPlayedDate);
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
index d7ae6a8a18..71b6551d0f 100644
--- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.MediaInfo;
using Moq;
using Xunit;
@@ -203,6 +204,50 @@ public class EncodingHelperTests
}
}
+ [Theory]
+ [InlineData("aac", 44100, 44100)] // non-opus: requested rate must be preserved (issue #17026)
+ [InlineData("aac", 48000, 48000)]
+ [InlineData("mp3", 22050, 22050)]
+ [InlineData("flac", 96000, 96000)]
+ [InlineData("opus", 44100, 48000)] // opus: must snap to a libopus-supported rate
+ [InlineData("opus", 22050, 24000)]
+ [InlineData("opus", 8000, 8000)]
+ public void GetProgressiveAudioFullCommandLine_SampleRate_OnlyClampedForOpus(
+ string audioCodec,
+ int requestedSampleRate,
+ int expectedSampleRate)
+ {
+ var state = BuildAudioState(audioCodec, requestedSampleRate);
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out");
+
+ Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal);
+ }
+
+ private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate)
+ {
+ var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 };
+
+ return new EncodingJobInfo(TranscodingJobType.Progressive)
+ {
+ MediaSource = new MediaSourceInfo
+ {
+ Container = "flac",
+ MediaStreams = new List<MediaStream> { audio },
+ Path = "/media/track.flac",
+ Protocol = MediaProtocol.File,
+ },
+ AudioStream = audio,
+ OutputAudioCodec = audioCodec,
+ BaseRequest = new VideoRequestDto
+ {
+ AudioCodec = audioCodec,
+ AudioSampleRate = requestedSampleRate,
+ },
+ IsVideoRequest = false,
+ IsInputVideo = false,
+ };
+ }
+
private static EncodingJobInfo BuildState(
MediaStream? subtitle,
SubtitleDeliveryMethod? deliveryMethod,
diff --git a/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs
new file mode 100644
index 0000000000..71fd853ba2
--- /dev/null
+++ b/tests/Jellyfin.Extensions.Tests/PathHelperTests.cs
@@ -0,0 +1,60 @@
+using System.IO;
+using Jellyfin.Extensions;
+using Xunit;
+
+namespace Jellyfin.Extensions.Tests
+{
+ public static class PathHelperTests
+ {
+ [Theory]
+ [InlineData("file.txt", "file.txt")]
+ [InlineData("sub/file.txt", "file.txt")]
+ [InlineData("../../etc/passwd", "passwd")]
+ public static void GetSafeLeafFileName_ReducesToLeaf(string input, string expected)
+ {
+ Assert.Equal(expected, PathHelper.GetSafeLeafFileName(input));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(".")]
+ [InlineData("..")]
+ public static void GetSafeLeafFileName_RejectsUnusableLeaf(string? input)
+ {
+ Assert.Null(PathHelper.GetSafeLeafFileName(input));
+ }
+
+ [Fact]
+ public static void IsContainedIn_ChildPath_ReturnsTrue()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ var child = Path.Combine(root, "sub", "file.txt");
+ Assert.True(PathHelper.IsContainedIn(root, child));
+ }
+
+ [Fact]
+ public static void IsContainedIn_RootItself_ReturnsTrue()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ Assert.True(PathHelper.IsContainedIn(root, root));
+ }
+
+ [Fact]
+ public static void IsContainedIn_TraversalEscape_ReturnsFalse()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "root");
+ var escape = Path.Combine(root, "..", "..", "etc", "passwd");
+ Assert.False(PathHelper.IsContainedIn(root, escape));
+ }
+
+ [Fact]
+ public static void IsContainedIn_SiblingPrefixCollision_ReturnsFalse()
+ {
+ // "/var/data" must not be accepted as a parent of "/var/dataset".
+ var root = Path.Combine(Path.GetTempPath(), "data");
+ var sibling = Path.Combine(Path.GetTempPath(), "dataset", "file.txt");
+ Assert.False(PathHelper.IsContainedIn(root, sibling));
+ }
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
index b71dc15201..f698edc637 100644
--- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using AutoFixture;
using AutoFixture.AutoMoq;
using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.LiveTv;
using Moq;
using Moq.Protected;
@@ -66,6 +67,7 @@ public class XmlTvListingsProviderTests
Assert.True(program.HasImage);
Assert.Equal("https://domain.tld/image.png", program.ImageUrl);
Assert.Equal("3297", program.ChannelId);
+ AssertXmlTvEtag(program.Etag);
}
[Theory]
@@ -85,5 +87,60 @@ public class XmlTvListingsProviderTests
var program = programsList[0];
Assert.DoesNotContain(program.Genres, g => string.IsNullOrEmpty(g));
Assert.Equal("3297", program.ChannelId);
+ AssertXmlTvEtag(program.Etag);
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_Etag_SameContentIsStable()
+ {
+ var first = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+ var second = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+
+ Assert.Equal(first.Etag, second.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml")]
+ public async Task GetProgramsAsync_Etag_ChangesWhenMappedContentChanges(string changedPath)
+ {
+ var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+ var changed = await GetSingleProgramAsync(changedPath);
+
+ Assert.NotEqual(original.Etag, changed.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml")]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml")]
+ public async Task GetProgramsAsync_Etag_DoesNotChangeWhenMappedContentIsEquivalent(string equivalentPath)
+ {
+ var original = await GetSingleProgramAsync("Test Data/LiveTv/Listings/XmlTv/etag-base.xml");
+ var equivalent = await GetSingleProgramAsync(equivalentPath);
+
+ Assert.Equal(original.Etag, equivalent.Etag);
+ }
+
+ private async Task<ProgramInfo> GetSingleProgramAsync(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = Path.GetFileNameWithoutExtension(path),
+ Path = path
+ };
+
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None);
+
+ return Assert.Single(programs.ToList());
+ }
+
+ private static void AssertXmlTvEtag(string? etag)
+ {
+ Assert.NotNull(etag);
+ Assert.StartsWith("xmltv-sha256-v1:", etag!, StringComparison.Ordinal);
}
}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs
new file mode 100644
index 0000000000..b8d1c60e1a
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvProgramEtagTests.cs
@@ -0,0 +1,59 @@
+using System;
+using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller.LiveTv;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public class XmlTvProgramEtagTests
+{
+ [Fact]
+ public void TryCreate_GenreOrderIsSignificant()
+ {
+ // GuideManager assigns item.Genres = info.Genres.ToArray() preserving order,
+ // so the same genres in a different order is a real mapped-content change.
+ var first = NewProgram();
+ first.Genres = new() { "Drama", "Action" };
+
+ var second = NewProgram();
+ second.Genres = new() { "Action", "Drama" };
+
+ Assert.True(XmlTvProgramEtag.TryCreate(first, out var firstEtag, out _));
+ Assert.True(XmlTvProgramEtag.TryCreate(second, out var secondEtag, out _));
+ Assert.NotEqual(firstEtag, secondEtag);
+ }
+
+ [Fact]
+ public void MatchesStored_EqualXmlTvEtags_ReturnsTrue()
+ {
+ const string Etag = XmlTvProgramEtag.Prefix + "ABCDEF0123456789";
+ Assert.True(XmlTvProgramEtag.MatchesStored(Etag, Etag));
+ }
+
+ [Fact]
+ public void MatchesStored_DifferentXmlTvEtags_ReturnsFalse()
+ {
+ Assert.False(XmlTvProgramEtag.MatchesStored(
+ XmlTvProgramEtag.Prefix + "AAAA",
+ XmlTvProgramEtag.Prefix + "BBBB"));
+ }
+
+ [Fact]
+ public void MatchesStored_EqualNonXmlTvEtags_ReturnsFalse()
+ {
+ // Other providers (e.g. Schedules Direct) use their own etag schemes.
+ // The IsXmlTvEtag gate must keep them on the field-by-field update path
+ // even when their incoming and stored values happen to match exactly.
+ const string Etag = "sd-abc123";
+ Assert.False(XmlTvProgramEtag.MatchesStored(Etag, Etag));
+ }
+
+ private static ProgramInfo NewProgram() => new()
+ {
+ Id = "program-id",
+ ChannelId = "channel-id",
+ Name = "Program Name",
+ StartDate = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc),
+ EndDate = new DateTime(2026, 1, 1, 13, 0, 0, DateTimeKind.Utc),
+ };
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
new file mode 100644
index 0000000000..f44cb88834
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
@@ -0,0 +1,51 @@
+using Jellyfin.LiveTv;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Entities;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests;
+
+public class LiveTvChannelImageHelperTests
+{
+ [Fact]
+ public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate()
+ {
+ var channel = new LiveTvChannel { Name = "Test Channel" };
+
+ var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null);
+
+ Assert.False(updated);
+ Assert.False(channel.HasImage(ImageType.Primary));
+ }
+
+ [Fact]
+ public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl()
+ {
+ var channel = new LiveTvChannel { Name = "Test Channel" };
+
+ var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
+ channel,
+ null,
+ "https://example.com/icon.png");
+
+ Assert.True(updated);
+ Assert.True(channel.HasImage(ImageType.Primary));
+ Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
+ }
+
+ [Fact]
+ public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates()
+ {
+ var channel = new LiveTvChannel { Name = "Test Channel" };
+ LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png");
+
+ var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
+ channel,
+ null,
+ "https://example.com/icon.png");
+
+ Assert.True(updated);
+ Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
index 59cd42c05b..1bc42d5fe5 100644
--- a/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/SchedulesDirect/SchedulesDirectDeserializeTests.cs
@@ -176,6 +176,30 @@ namespace Jellyfin.LiveTv.Tests.SchedulesDirect
}
/// <summary>
+ /// /metadata/programs response where the daily image limit is hit mid-batch,
+ /// so individual entries carry an error code inside an otherwise successful response.
+ /// </summary>
+ [Fact]
+ public void Deserialize_Metadata_Programs_Image_Limit_Response_Success()
+ {
+ var bytes = File.ReadAllBytes("Test Data/SchedulesDirect/metadata_programs_image_limit_response.json");
+ var showImagesDtos = JsonSerializer.Deserialize<IReadOnlyList<ShowImagesDto>>(bytes, _jsonOptions);
+
+ Assert.NotNull(showImagesDtos);
+ Assert.Equal(2, showImagesDtos!.Count);
+
+ // First entry is a normal result with image data and no error code.
+ Assert.Equal("SH00712240", showImagesDtos[0].ProgramId);
+ Assert.Null(showImagesDtos[0].Code);
+ Assert.Single(showImagesDtos[0].Data);
+
+ // Second entry is a per-entry trial image download limit error (SD code 5003).
+ Assert.Equal("SH00712241", showImagesDtos[1].ProgramId);
+ Assert.Equal((int)SdErrorCode.MaxImageDownloadsTrial, showImagesDtos[1].Code);
+ Assert.Empty(showImagesDtos[1].Data);
+ }
+
+ /// <summary>
/// /headends response.
/// </summary>
[Fact]
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml
new file mode 100644
index 0000000000..15f85f57e6
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-base.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml
new file mode 100644
index 0000000000..2b49c3bccd
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-category-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">sports</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml
new file mode 100644
index 0000000000..090273ac98
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-description-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Changed description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml
new file mode 100644
index 0000000000..532b91da20
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-icon-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/changed.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml
new file mode 100644
index 0000000000..db0d5e86de
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-progid-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789013</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml
new file mode 100644
index 0000000000..168c0a643b
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-reordered.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" stop="20221104140000 +0000" start="20221104130000 +0000">
+ <icon src="https://domain.tld/base.png"/>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <category lang="en">series</category>
+ <desc lang="en">Base description.</desc>
+ <sub-title lang="en">Base Episode</sub-title>
+ <title lang="en">Base Program</title>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml
new file mode 100644
index 0000000000..73288e7c57
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-title-change.xml
@@ -0,0 +1,17 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Changed Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml
new file mode 100644
index 0000000000..d0ff1b82f5
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/etag-unknown-field.xml
@@ -0,0 +1,18 @@
+<tv date="20221104">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221104140000 +0000">
+ <title lang="en">Base Program</title>
+ <sub-title lang="en">Base Episode</sub-title>
+ <desc lang="en">Base description.</desc>
+ <category lang="en">series</category>
+ <episode-num system="xmltv_ns">0 . 1 . </episode-num>
+ <episode-num system="dd_progid">EP123456789012</episode-num>
+ <rating system="VCHIP">
+ <value>TV-G</value>
+ </rating>
+ <star-rating>
+ <value>3/5</value>
+ </star-rating>
+ <previously-unknown-field>Ignored by Jellyfin XMLTV mapping.</previously-unknown-field>
+ <icon src="https://domain.tld/base.png"/>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json
new file mode 100644
index 0000000000..34931aa769
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/SchedulesDirect/metadata_programs_image_limit_response.json
@@ -0,0 +1 @@
+[{"programID":"SH00712240","data":[{"width":"135","height":"180","uri":"assets/p282288_b_v2_aa.jpg","size":"Sm","aspect":"3x4","category":"Banner-L3","text":"yes","primary":"true","tier":"Series"}]},{"programID":"SH00712241","code":5003,"message":"Image download limit exceeded. Try again tomorrow."}]
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs
new file mode 100644
index 0000000000..9847acbb0a
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs
@@ -0,0 +1,22 @@
+using System;
+using System.Runtime.Versioning;
+using MediaBrowser.MediaEncoding.Encoder;
+using Xunit;
+
+namespace Jellyfin.MediaEncoding.Tests;
+
+[SupportedOSPlatform("macos")]
+public class ApplePlatformHelperTests
+{
+ [Fact]
+ public void GetSysctlValue_CpuBrand_NotEmpty()
+ {
+ Assert.SkipUnless(OperatingSystem.IsMacOS(), "macOS-only test");
+
+ var value = ApplePlatformHelper.GetSysctlValue("machdep.cpu.brand_string");
+ Assert.NotEmpty(value);
+
+ // Make sure we don't include the null terminator
+ Assert.DoesNotContain("\0", value, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
index 988073074b..bfe6ade1fe 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTests.cs
@@ -24,6 +24,7 @@ namespace Jellyfin.MediaEncoding.Tests
[InlineData(EncoderValidatorTestsData.FFmpegV44Output, true)]
[InlineData(EncoderValidatorTestsData.FFmpegV432Output, false)]
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, true)]
+ [InlineData(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, true)]
[InlineData(EncoderValidatorTestsData.FFmpegGitUnknownOutput, false)]
public void ValidateVersionInternalTest(string versionOutput, bool valid)
{
@@ -41,6 +42,7 @@ namespace Jellyfin.MediaEncoding.Tests
Add(EncoderValidatorTestsData.FFmpegV44Output, new Version(4, 4));
Add(EncoderValidatorTestsData.FFmpegV432Output, new Version(4, 3, 2));
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput2, new Version(4, 4));
+ Add(EncoderValidatorTestsData.FFmpegGitWithoutLibpostprocOutput, new Version(4, 4));
Add(EncoderValidatorTestsData.FFmpegGitUnknownOutput, null);
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
index 1f2d618aa4..604b862fbe 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/EncoderValidatorTestsData.cs
@@ -86,6 +86,15 @@ libswscale 5. 9.100 / 5. 9.100
libswresample 3. 9.100 / 3. 9.100
libpostproc 55. 9.100 / 55. 9.100";
+ public const string FFmpegGitWithoutLibpostprocOutput = @"ffmpeg version N-122128-gdeadbeef Copyright (c) 2000-2026 the FFmpeg developers
+libavutil 60. 26.102 / 60. 26.102
+libavcodec 62. 28.102 / 62. 28.102
+libavformat 62. 12.102 / 62. 12.102
+libavdevice 62. 3.102 / 62. 3.102
+libavfilter 11. 14.102 / 11. 14.102
+libswscale 9. 5.102 / 9. 5.102
+libswresample 6. 3.102 / 6. 3.102";
+
public const string FFmpegGitUnknownOutput = @"ffmpeg version N-45325-gb173e0353-static https://johnvansickle.com/ffmpeg/ Copyright (c) 2000-2018 the FFmpeg developers
built with gcc 6.3.0 (Debian 6.3.0-18+deb9u1) 20170516
configuration: --enable-gpl --enable-version3 --enable-static --disable-debug --disable-ffplay --disable-indev=sndio --disable-outdev=sndio --cc=gcc-6 --enable-fontconfig --enable-frei0r --enable-gnutls --enable-gray --enable-libfribidi --enable-libass --enable-libfreetype --enable-libmp3lame --enable-libopencore-amrnb --enable-libopencore-amrwb --enable-libopenjpeg --enable-librubberband --enable-libsoxr --enable-libspeex --enable-libvorbis --enable-libopus --enable-libtheora --enable-libvidstab --enable-libvo-amrwbenc --enable-libvpx --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxvid --enable-libzimg
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
index 198cdaa4fc..b723fc7208 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Globalization;
using System.IO;
+using System.Linq;
using System.Text.Json;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json;
@@ -56,6 +57,43 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected)
=> Assert.Equal(expected, ProbeResultNormalizer.IsNearSquarePixelSar(sar));
+ [Theory]
+ // Lossy codecs, mono/stereo and multichannel.
+ [InlineData("aac", null, 2, 192000)]
+ [InlineData("mp3", null, 2, 192000)]
+ [InlineData("mp2", null, 2, 192000)]
+ [InlineData("aac", null, 6, 320000)]
+ [InlineData("ac3", null, 2, 192000)]
+ [InlineData("eac3", null, 6, 640000)]
+ [InlineData("opus", null, 2, 128000)]
+ [InlineData("vorbis", null, 6, 320000)]
+ [InlineData("wmav2", null, 2, 192000)]
+ // DTS: the lossy core (any non-MA profile, or none) is flat and caps at 5.1...
+ [InlineData("dts", null, 2, 768000)]
+ [InlineData("dts", "DTS", 6, 1509000)]
+ [InlineData("dts", "DTS-HD HRA", 8, 1509000)]
+ // ...while lossless DTS-HD MA scales per channel like other lossless codecs.
+ [InlineData("dts", "DTS-HD MA", 6, 4200000)]
+ [InlineData("dts", "DTS-HD MA + DTS:X", 8, 5600000)]
+ // Lossless codecs scale per channel.
+ [InlineData("flac", null, 2, 960000)]
+ [InlineData("flac", null, 6, 2880000)]
+ [InlineData("flac", null, 8, 3840000)]
+ [InlineData("alac", null, 6, 2880000)]
+ [InlineData("truehd", null, 2, 1400000)]
+ [InlineData("truehd", null, 6, 4200000)]
+ [InlineData("truehd", "Dolby TrueHD + Dolby Atmos", 8, 5600000)]
+ // 3-4 channel audio must use the multichannel estimate, not return null.
+ [InlineData("aac", null, 3, 320000)]
+ [InlineData("ac3", null, 4, 640000)]
+ // Codec matching is case-insensitive.
+ [InlineData("AAC", null, 2, 192000)]
+ // Unknown codec or unknown channel count cannot be estimated.
+ [InlineData("pcm_s16le", null, 2, null)]
+ [InlineData("aac", null, null, null)]
+ public void GetEstimatedAudioBitrate_ReturnsExpected(string codec, string? profile, int? channels, int? expected)
+ => Assert.Equal(expected, ProbeResultNormalizer.GetEstimatedAudioBitrate(codec, profile, channels));
+
[Fact]
public void GetMediaInfo_MetaData_Success()
{
@@ -71,7 +109,10 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.Equal("4:3", res.VideoStream.AspectRatio);
Assert.Equal(25f, res.VideoStream.AverageFrameRate);
Assert.Equal(8, res.VideoStream.BitDepth);
- Assert.Equal(69432, res.VideoStream.BitRate);
+ // ffprobe reports no per-stream video bitrate here. The container bitrate must not be
+ // misreported as the video bitrate, and the other streams' bitrates exceed the container
+ // bitrate in this sample, so no sensible video bitrate can be inferred (see #16248).
+ Assert.Null(res.VideoStream.BitRate);
Assert.Equal("h264", res.VideoStream.Codec);
Assert.Equal("1/50", res.VideoStream.CodecTimeBase);
Assert.Equal(240, res.VideoStream.Height);
@@ -322,6 +363,73 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
}
[Fact]
+ public void GetMediaInfo_MissingVideoBitrate_EstimatedFromContainer()
+ {
+ // ffprobe did not report a per-stream video bitrate. The video bitrate must be estimated
+ // as the container bitrate minus the other (audio) stream bitrates, not reported as the
+ // whole container bitrate (see #16248).
+ var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate.json");
+
+ var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
+ MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate.mp4", MediaProtocol.File);
+
+ Assert.Equal(2, res.MediaStreams.Count);
+
+ Assert.NotNull(res.VideoStream);
+ Assert.Equal(MediaStreamType.Video, res.VideoStream.Type);
+
+ var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio);
+ Assert.Equal(128000, audioStream.BitRate);
+
+ // Container bitrate (5128000) minus the audio bitrate (128000).
+ Assert.Equal(5000000, res.VideoStream.BitRate);
+
+ // The container bitrate itself must remain the overall container bitrate.
+ Assert.Equal(5128000, res.Bitrate);
+ }
+
+ [Fact]
+ public void GetMediaInfo_NanosecondDurationTag_BitrateComputedFromBytes()
+ {
+ // The stream carries NUMBER_OF_BYTES and a nanosecond-precision DURATION tag but no
+ // bitrate. TimeSpan only supports 7 fractional digits, so the 9-digit DURATION must be
+ // trimmed for the duration to parse and the bitrate to be computed (bytes * 8 / seconds).
+ var bytes = File.ReadAllBytes("Test Data/Probing/video_nanosecond_duration_bitrate.json");
+
+ var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
+ MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_nanosecond_duration_bitrate.mkv", MediaProtocol.File);
+
+ Assert.NotNull(res.VideoStream);
+
+ // 10000000 bytes * 8 / 100 seconds.
+ Assert.Equal(800000, res.VideoStream.BitRate);
+ }
+
+ [Fact]
+ public void GetMediaInfo_MissingVideoBitrate_UnknownAudioBitrate_NotEstimated()
+ {
+ // ffprobe reported no per-stream video bitrate and the audio bitrate cannot be estimated
+ // (the audio stream has no channel count, so GetEstimatedAudioBitrate returns null). The
+ // video bitrate must be left unset rather than wrongly absorbing the unaccounted audio
+ // bitrate (see #16248).
+ var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate_unknown_audio.json");
+
+ var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
+ MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate_unknown_audio.mp4", MediaProtocol.File);
+
+ Assert.Equal(2, res.MediaStreams.Count);
+
+ Assert.NotNull(res.VideoStream);
+ Assert.Null(res.VideoStream.BitRate);
+
+ var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio);
+ Assert.Null(audioStream.BitRate);
+
+ // The overall container bitrate is still reported.
+ Assert.Equal(5128000, res.Bitrate);
+ }
+
+ [Fact]
public void GetMediaInfo_VideoWithSingleFrameMjpeg_Success()
{
var bytes = File.ReadAllBytes("Test Data/Probing/video_single_frame_mjpeg.json");
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
index 1f908d7e0e..b03651e5e9 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs
@@ -15,13 +15,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.ass");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "ass");
- Assert.Single(parsed.TrackEvents);
- var trackEvent = parsed.TrackEvents[0];
+ Assert.Single(parsed.Paragraphs);
+ var paragraph = parsed.Paragraphs[0];
- Assert.Equal("1", trackEvent.Id);
- Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks);
- Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", trackEvent.Text);
+ Assert.Equal(1, paragraph.Number);
+ Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks);
+ Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", paragraph.Text);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
index b7152961cd..01a35e6cb0 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs
@@ -15,19 +15,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.srt");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt");
- Assert.Equal(2, parsed.TrackEvents.Count);
-
- var trackEvent1 = parsed.TrackEvents[0];
- Assert.Equal("1", trackEvent1.Id);
- Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks);
- Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", trackEvent1.Text);
-
- var trackEvent2 = parsed.TrackEvents[1];
- Assert.Equal("2", trackEvent2.Id);
- Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks);
- Assert.Equal("Very good, Lieutenant.", trackEvent2.Text);
+ Assert.Equal(2, parsed.Paragraphs.Count);
+
+ var paragraph1 = parsed.Paragraphs[0];
+ Assert.Equal(1, paragraph1.Number);
+ Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks);
+ Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", paragraph1.Text);
+
+ var paragraph2 = parsed.Paragraphs[1];
+ Assert.Equal(2, paragraph2.Number);
+ Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks);
+ Assert.Equal("Very good, Lieutenant.", paragraph2.Text);
}
[Fact]
@@ -36,19 +36,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example2.srt");
var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt");
- Assert.Equal(2, parsed.TrackEvents.Count);
-
- var trackEvent1 = parsed.TrackEvents[0];
- Assert.Equal("311", trackEvent1.Id);
- Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks);
- Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", trackEvent1.Text);
-
- var trackEvent2 = parsed.TrackEvents[1];
- Assert.Equal("312", trackEvent2.Id);
- Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks);
- Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", trackEvent2.Text);
+ Assert.Equal(2, parsed.Paragraphs.Count);
+
+ var paragraph1 = parsed.Paragraphs[0];
+ Assert.Equal(311, paragraph1.Number);
+ Assert.Equal(TimeSpan.Parse("00:16:46.465", CultureInfo.InvariantCulture).Ticks, paragraph1.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:16:49.009", CultureInfo.InvariantCulture).Ticks, paragraph1.EndTime.TimeSpan.Ticks);
+ Assert.Equal("Una vez que la gente se entere" + Environment.NewLine + Environment.NewLine + "de que ustedes están aquí,", paragraph1.Text);
+
+ var paragraph2 = parsed.Paragraphs[1];
+ Assert.Equal(312, paragraph2.Number);
+ Assert.Equal(TimeSpan.Parse("00:16:49.092", CultureInfo.InvariantCulture).Ticks, paragraph2.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:16:51.470", CultureInfo.InvariantCulture).Ticks, paragraph2.EndTime.TimeSpan.Ticks);
+ Assert.Equal("este lugar se convertirá" + Environment.NewLine + Environment.NewLine + "en un maldito zoológico.", paragraph2.Text);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
index 5b7aa7eaa9..d814088593 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs
@@ -20,19 +20,19 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
{
using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(ssa));
- SubtitleTrackInfo subtitleTrackInfo = _parser.Parse(stream, "ssa");
+ var subtitle = _parser.Parse(stream, "ssa");
- Assert.Equal(expectedSubtitleTrackEvents.Count, subtitleTrackInfo.TrackEvents.Count);
+ Assert.Equal(expectedSubtitleTrackEvents.Count, subtitle.Paragraphs.Count);
for (int i = 0; i < expectedSubtitleTrackEvents.Count; ++i)
{
SubtitleTrackEvent expected = expectedSubtitleTrackEvents[i];
- SubtitleTrackEvent actual = subtitleTrackInfo.TrackEvents[i];
+ var actual = subtitle.Paragraphs[i];
- Assert.Equal(expected.Id, actual.Id);
+ Assert.Equal(expected.Id, actual.Number.ToString(CultureInfo.InvariantCulture));
Assert.Equal(expected.Text, actual.Text);
- Assert.Equal(expected.StartPositionTicks, actual.StartPositionTicks);
- Assert.Equal(expected.EndPositionTicks, actual.EndPositionTicks);
+ Assert.Equal(expected.StartPositionTicks, actual.StartTime.TimeSpan.Ticks);
+ Assert.Equal(expected.EndPositionTicks, actual.EndTime.TimeSpan.Ticks);
}
}
@@ -75,13 +75,13 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
using var stream = File.OpenRead("Test Data/example.ssa");
var parsed = _parser.Parse(stream, "ssa");
- Assert.Single(parsed.TrackEvents);
- var trackEvent = parsed.TrackEvents[0];
+ Assert.Single(parsed.Paragraphs);
+ var paragraph = parsed.Paragraphs[0];
- Assert.Equal("1", trackEvent.Id);
- Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks);
- Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks);
- Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", trackEvent.Text);
+ Assert.Equal(1, paragraph.Number);
+ Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, paragraph.StartTime.TimeSpan.Ticks);
+ Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, paragraph.EndTime.TimeSpan.Ticks);
+ Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", paragraph.Text);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
index ce1f005f40..48850b2f67 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs
@@ -1,3 +1,8 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutoFixture;
@@ -6,87 +11,120 @@ using MediaBrowser.MediaEncoding.Subtitles;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.MediaEncoding.Subtitles.Tests
{
public class SubtitleEncoderTests
{
- public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData()
+ private const int StreamCount = 8;
+ private const int CueCount = 500;
+
+ // A Greek line that requires a non-UTF-8 legacy encoding to reproduce the bug. The accented
+ // characters (ά, έ, ή, ί, ό, ύ, ώ) share the same code points in windows-1253 and iso-8859-7,
+ // so a Greek-vs-Greek charset misdetection still round-trips correctly.
+ private const string GreekText = "Καλημέρα κόσμε, αυτό είναι ένας υπότιτλος.";
+
+ static SubtitleEncoderTests()
{
- var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>();
+ // Mirrors Jellyfin.Server startup so legacy code pages (e.g. Greek windows-1253) are available.
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ }
- data.Add(
- new MediaSourceInfo()
- {
- Protocol = MediaProtocol.File
- },
- new MediaStream()
- {
- Path = "/media/sub.ass",
- IsExternal = true
- },
- new SubtitleEncoder.SubtitleInfo()
- {
- Path = "/media/sub.ass",
- Protocol = MediaProtocol.File,
- Format = "ass",
- IsExternal = true
- });
+ // Enough Greek text to give the charset detector a strong, unambiguous signal.
+ private static string BuildGreekSrt()
+ {
+ var builder = new StringBuilder();
+ for (var i = 1; i <= 8; i++)
+ {
+ builder.Append(i.ToString(CultureInfo.InvariantCulture)).Append('\n');
+ builder.Append("00:00:0").Append(i.ToString(CultureInfo.InvariantCulture))
+ .Append(",000 --> 00:00:0").Append((i + 1).ToString(CultureInfo.InvariantCulture)).Append(",000\n");
+ builder.Append(GreekText).Append('\n');
+ builder.Append("Η γρήγορη καφέ αλεπού πηδάει πάνω από το τεμπέλικο σκυλί.\n\n");
+ }
- data.Add(
- new MediaSourceInfo()
- {
- Protocol = MediaProtocol.File
- },
- new MediaStream()
- {
- Path = "/media/sub.ssa",
- IsExternal = true
- },
- new SubtitleEncoder.SubtitleInfo()
- {
- Path = "/media/sub.ssa",
- Protocol = MediaProtocol.File,
- Format = "ssa",
- IsExternal = true
- });
+ return builder.ToString();
+ }
- data.Add(
- new MediaSourceInfo()
- {
- Protocol = MediaProtocol.File
- },
- new MediaStream()
+ public static TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo> GetReadableFile_Valid_TestData()
+ {
+ var data = new TheoryData<MediaSourceInfo, MediaStream, SubtitleEncoder.SubtitleInfo>
+ {
{
- Path = "/media/sub.srt",
- IsExternal = true
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.File
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.ass",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ass",
+ Protocol = MediaProtocol.File,
+ Format = "ass",
+ IsExternal = true
+ }
},
- new SubtitleEncoder.SubtitleInfo()
- {
- Path = "/media/sub.srt",
- Protocol = MediaProtocol.File,
- Format = "srt",
- IsExternal = true
- });
-
- data.Add(
- new MediaSourceInfo()
{
- Protocol = MediaProtocol.Http
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.File
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.ssa",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ssa",
+ Protocol = MediaProtocol.File,
+ Format = "ssa",
+ IsExternal = true
+ }
},
- new MediaStream()
{
- Path = "/media/sub.ass",
- IsExternal = true
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.File
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.srt",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.srt",
+ Protocol = MediaProtocol.File,
+ Format = "srt",
+ IsExternal = true
+ }
},
- new SubtitleEncoder.SubtitleInfo()
{
- Path = "/media/sub.ass",
- Protocol = MediaProtocol.File,
- Format = "ass",
- IsExternal = true
- });
+ new MediaSourceInfo()
+ {
+ Protocol = MediaProtocol.Http
+ },
+ new MediaStream()
+ {
+ Path = "/media/sub.ass",
+ IsExternal = true
+ },
+ new SubtitleEncoder.SubtitleInfo()
+ {
+ Path = "/media/sub.ass",
+ Protocol = MediaProtocol.File,
+ Format = "ass",
+ IsExternal = true
+ }
+ }
+ };
return data;
}
@@ -103,5 +141,177 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests
Assert.Equal(subtitleInfo.Format, result.Format);
Assert.Equal(subtitleInfo.IsExternal, result.IsExternal);
}
+
+ public static TheoryData<Encoding> GetSubtitleStream_NonUtf8LocalFile_TestData()
+ {
+ return
+ [
+ // Greek legacy encodings – the exact scenario reported in issue #17267.
+ Encoding.GetEncoding("windows-1253"),
+ Encoding.GetEncoding("iso-8859-7"),
+ // Wide encoding with a BOM.
+ new UnicodeEncoding(bigEndian: false, byteOrderMark: true),
+ ];
+ }
+
+ [Theory]
+ [MemberData(nameof(GetSubtitleStream_NonUtf8LocalFile_TestData))]
+ public async Task GetSubtitleStream_NonUtf8LocalFile_ConvertedToUtf8(Encoding sourceEncoding)
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var srt = BuildGreekSrt();
+ var path = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(path, srt, sourceEncoding, cancellationToken);
+
+ var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
+ var subtitleEncoder = fixture.Create<SubtitleEncoder>();
+
+ var fileInfo = new SubtitleEncoder.SubtitleInfo
+ {
+ Path = path,
+ Protocol = MediaProtocol.File,
+ Format = "srt",
+ IsExternal = true
+ };
+
+ using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken);
+ using var reader = new StreamReader(stream, new UTF8Encoding(false));
+ var text = await reader.ReadToEndAsync(cancellationToken);
+
+ // The Greek text must survive round-trip and contain no replacement characters.
+ Assert.Contains(GreekText, text, StringComparison.Ordinal);
+ Assert.DoesNotContain('�', text);
+ Assert.DoesNotContain('?', text);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void ConvertSubtitles_SequentialCalls_AreDeterministic()
+ {
+ using var encoder = CreateEncoder();
+ var sources = GenerateSources();
+
+ var first = ConvertAllSequential(encoder, sources);
+ var second = ConvertAllSequential(encoder, sources);
+
+ for (var i = 0; i < StreamCount; i++)
+ {
+ Assert.Contains($"S{i}C{CueCount - 1}", first[i], StringComparison.Ordinal);
+ Assert.Equal(first[i], second[i]);
+ }
+ }
+
+ [Fact]
+ public async Task GetSubtitleStream_Utf8LocalFile_PreservesContent()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var srt = BuildGreekSrt();
+ var path = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(path, srt, new UTF8Encoding(false), cancellationToken);
+
+ var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
+ var subtitleEncoder = fixture.Create<SubtitleEncoder>();
+
+ var fileInfo = new SubtitleEncoder.SubtitleInfo
+ {
+ Path = path,
+ Protocol = MediaProtocol.File,
+ Format = "srt",
+ IsExternal = true
+ };
+
+ using var stream = await subtitleEncoder.GetSubtitleStream(fileInfo, cancellationToken);
+
+ // An already-UTF-8 file must be short-circuited and served directly from disk,
+ // not read into memory and re-encoded (which would produce a MemoryStream).
+ Assert.IsNotType<MemoryStream>(stream);
+
+ using var reader = new StreamReader(stream, new UTF8Encoding(false));
+ var text = await reader.ReadToEndAsync(cancellationToken);
+
+ Assert.Contains(GreekText, text, StringComparison.Ordinal);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public async Task ConvertSubtitles_ConcurrentCalls_MatchSequentialBaseline()
+ {
+ const int Iterations = 10;
+
+ using var encoder = CreateEncoder();
+ var sources = GenerateSources();
+ var baseline = ConvertAllSequential(encoder, sources);
+
+ for (var iteration = 0; iteration < Iterations; iteration++)
+ {
+ var results = await Task.WhenAll(Enumerable.Range(0, StreamCount)
+ .Select(i => Task.Run(() => Convert(encoder, sources[i], i)))
+ .ToArray());
+
+ for (var i = 0; i < StreamCount; i++)
+ {
+ Assert.True(
+ string.Equals(baseline[i], results[i], StringComparison.Ordinal),
+ $"Iteration {iteration}: stream {i} returned corrupted content ({results[i].Length} chars vs {baseline[i].Length} baseline)");
+ }
+ }
+ }
+
+ private static SubtitleEncoder CreateEncoder()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
+ fixture.Inject<ISubtitleParser>(new SubtitleEditParser(NullLogger<SubtitleEditParser>.Instance));
+ return fixture.Create<SubtitleEncoder>();
+ }
+
+ private static byte[][] GenerateSources()
+ {
+ return Enumerable.Range(0, StreamCount)
+ .Select(i => Encoding.UTF8.GetBytes(GenerateSrt(i, CueCount)))
+ .ToArray();
+ }
+
+ private static string Convert(SubtitleEncoder encoder, byte[] source, int streamIndex)
+ {
+ using var input = new MemoryStream(source);
+ var info = new SubtitleEncoder.SubtitleInfo { Path = $"track{streamIndex}.srt", Format = "srt" };
+ using var output = encoder.ConvertSubtitles(input, info, "vtt", 0, 0, false);
+ return Encoding.UTF8.GetString(output.ToArray());
+ }
+
+ private static string[] ConvertAllSequential(SubtitleEncoder encoder, byte[][] sources)
+ {
+ return sources.Select((source, i) => Convert(encoder, source, i)).ToArray();
+ }
+
+ private static string GenerateSrt(int streamIndex, int cueCount)
+ {
+ var builder = new StringBuilder();
+ for (var i = 0; i < cueCount; i++)
+ {
+ var start = TimeSpan.FromSeconds(i * 4);
+ var end = start + TimeSpan.FromSeconds(2);
+ builder.Append(i + 1).AppendLine()
+ .Append(start.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture))
+ .Append(" --> ")
+ .AppendLine(end.ToString(@"hh\:mm\:ss\,fff", CultureInfo.InvariantCulture))
+ .Append('S').Append(streamIndex).Append('C').Append(i).AppendLine()
+ .AppendLine();
+ }
+
+ return builder.ToString();
+ }
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json
new file mode 100644
index 0000000000..803a3a7e5f
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate.json
@@ -0,0 +1,113 @@
+{
+ "streams": [
+ {
+ "index": 0,
+ "codec_name": "h264",
+ "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
+ "profile": "High",
+ "codec_type": "video",
+ "codec_time_base": "1/48",
+ "codec_tag_string": "avc1",
+ "codec_tag": "0x31637661",
+ "width": 1920,
+ "height": 1080,
+ "coded_width": 1920,
+ "coded_height": 1080,
+ "closed_captions": 0,
+ "has_b_frames": 2,
+ "pix_fmt": "yuv420p",
+ "level": 40,
+ "chroma_location": "left",
+ "refs": 1,
+ "is_avc": "true",
+ "nal_length_size": "4",
+ "r_frame_rate": "24/1",
+ "avg_frame_rate": "24/1",
+ "time_base": "1/12288",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "duration_ts": 3686400,
+ "duration": "300.000000",
+ "bits_per_raw_sample": "8",
+ "nb_frames": "7200",
+ "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
+ },
+ "tags": {
+ "language": "und",
+ "handler_name": "VideoHandler"
+ }
+ },
+ {
+ "index": 1,
+ "codec_name": "aac",
+ "codec_long_name": "AAC (Advanced Audio Coding)",
+ "profile": "LC",
+ "codec_type": "audio",
+ "codec_time_base": "1/48000",
+ "codec_tag_string": "mp4a",
+ "codec_tag": "0x6134706d",
+ "sample_fmt": "fltp",
+ "sample_rate": "48000",
+ "channels": 2,
+ "channel_layout": "stereo",
+ "bits_per_sample": 0,
+ "r_frame_rate": "0/0",
+ "avg_frame_rate": "0/0",
+ "time_base": "1/48000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "duration_ts": 14400000,
+ "duration": "300.000000",
+ "bit_rate": "128000",
+ "nb_frames": "14063",
+ "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
+ },
+ "tags": {
+ "language": "eng",
+ "handler_name": "SoundHandler"
+ }
+ }
+ ],
+ "format": {
+ "filename": "test.1080p.mp4",
+ "nb_streams": 2,
+ "nb_programs": 0,
+ "format_name": "mov,mp4,m4a,3gp,3g2,mj2",
+ "format_long_name": "QuickTime / MOV",
+ "start_time": "0.000000",
+ "duration": "300.000000",
+ "size": "192000000",
+ "bit_rate": "5128000",
+ "probe_score": 100,
+ "tags": {
+ "major_brand": "isom",
+ "minor_version": "512",
+ "compatible_brands": "isomiso2avc1mp41",
+ "encoder": "Lavf58.20.100"
+ }
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json
new file mode 100644
index 0000000000..ff6dc51f27
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_missing_video_bitrate_unknown_audio.json
@@ -0,0 +1,110 @@
+{
+ "streams": [
+ {
+ "index": 0,
+ "codec_name": "h264",
+ "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
+ "profile": "High",
+ "codec_type": "video",
+ "codec_time_base": "1/48",
+ "codec_tag_string": "avc1",
+ "codec_tag": "0x31637661",
+ "width": 1920,
+ "height": 1080,
+ "coded_width": 1920,
+ "coded_height": 1080,
+ "closed_captions": 0,
+ "has_b_frames": 2,
+ "pix_fmt": "yuv420p",
+ "level": 40,
+ "chroma_location": "left",
+ "refs": 1,
+ "is_avc": "true",
+ "nal_length_size": "4",
+ "r_frame_rate": "24/1",
+ "avg_frame_rate": "24/1",
+ "time_base": "1/12288",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "duration_ts": 3686400,
+ "duration": "300.000000",
+ "bits_per_raw_sample": "8",
+ "nb_frames": "7200",
+ "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
+ },
+ "tags": {
+ "language": "und",
+ "handler_name": "VideoHandler"
+ }
+ },
+ {
+ "index": 1,
+ "codec_name": "dts",
+ "codec_long_name": "DCA (DTS Coherent Acoustics)",
+ "profile": "DTS-HD MA",
+ "codec_type": "audio",
+ "codec_time_base": "1/48000",
+ "codec_tag_string": "[0][0][0][0]",
+ "codec_tag": "0x0000",
+ "sample_fmt": "s32p",
+ "sample_rate": "48000",
+ "bits_per_sample": 0,
+ "r_frame_rate": "0/0",
+ "avg_frame_rate": "0/0",
+ "time_base": "1/48000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "duration_ts": 14400000,
+ "duration": "300.000000",
+ "nb_frames": "14063",
+ "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
+ },
+ "tags": {
+ "language": "eng",
+ "handler_name": "SoundHandler"
+ }
+ }
+ ],
+ "format": {
+ "filename": "test.1080p.mp4",
+ "nb_streams": 2,
+ "nb_programs": 0,
+ "format_name": "mov,mp4,m4a,3gp,3g2,mj2",
+ "format_long_name": "QuickTime / MOV",
+ "start_time": "0.000000",
+ "duration": "300.000000",
+ "size": "192000000",
+ "bit_rate": "5128000",
+ "probe_score": 100,
+ "tags": {
+ "major_brand": "isom",
+ "minor_version": "512",
+ "compatible_brands": "isomiso2avc1mp41",
+ "encoder": "Lavf58.20.100"
+ }
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json
new file mode 100644
index 0000000000..ff8b2ca80a
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Test Data/Probing/video_nanosecond_duration_bitrate.json
@@ -0,0 +1,49 @@
+{
+ "streams": [
+ {
+ "index": 0,
+ "codec_name": "h264",
+ "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
+ "profile": "High",
+ "codec_type": "video",
+ "codec_tag_string": "[0][0][0][0]",
+ "codec_tag": "0x0000",
+ "width": 1920,
+ "height": 1080,
+ "coded_width": 1920,
+ "coded_height": 1080,
+ "has_b_frames": 2,
+ "pix_fmt": "yuv420p",
+ "level": 40,
+ "r_frame_rate": "24/1",
+ "avg_frame_rate": "24/1",
+ "time_base": "1/1000",
+ "start_pts": 0,
+ "start_time": "0.000000",
+ "disposition": {
+ "default": 1
+ },
+ "tags": {
+ "language": "eng",
+ "BPS-eng": "",
+ "DURATION-eng": "00:01:40.000000000",
+ "NUMBER_OF_FRAMES-eng": "2400",
+ "NUMBER_OF_BYTES-eng": "10000000"
+ }
+ }
+ ],
+ "format": {
+ "filename": "video_nanosecond_duration_bitrate.mkv",
+ "nb_streams": 1,
+ "nb_programs": 0,
+ "format_name": "matroska,webm",
+ "format_long_name": "Matroska / WebM",
+ "start_time": "0.000000",
+ "duration": "100.000000",
+ "size": "10001000",
+ "probe_score": 100,
+ "tags": {
+ "encoder": "libebml v1.4.2 + libmatroska v1.6.4"
+ }
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index 16c586bcda..d94d56bc20 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -81,25 +81,25 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidPixel", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450
[InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450
[InlineData("AndroidPixel", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450
- [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
// Yatse
[InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
[InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)]
[InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
- [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("Yatse", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// RokuSSPlus
[InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 should be DirectPlay
[InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
[InlineData("RokuSSPlus", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// JellyfinMediaPlayer
[InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
@@ -118,21 +118,21 @@ namespace Jellyfin.Model.Tests
[InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")]
[InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode", "http")]
[InlineData("Chrome-NoHLS", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode", "http")]
- [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported, "DirectStream", "http")] // webm requested, aac not supported
+ [InlineData("Chrome-NoHLS", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // webm requested, aac not supported
[InlineData("Chrome-NoHLS", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerNotSupported | TranscodeReason.AudioCodecNotSupported, "DirectStream", "http")] // #6450
[InlineData("Chrome-NoHLS", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux", "http")] // #6450
// TranscodeMedia
[InlineData("TranscodeMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
[InlineData("TranscodeMedia", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
[InlineData("TranscodeMedia", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
+ [InlineData("TranscodeMedia", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "HLS.mp4")]
[InlineData("TranscodeMedia", "mp4-hevc-ac3-aacDef-srt-15200k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "HLS.mp4")]
- [InlineData("TranscodeMedia", "mkv-av1-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")]
+ [InlineData("TranscodeMedia", "mkv-av1-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")]
[InlineData("TranscodeMedia", "mkv-av1-vorbis-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "http")]
- [InlineData("TranscodeMedia", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")]
- [InlineData("TranscodeMedia", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "DirectStream", "http")]
+ [InlineData("TranscodeMedia", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")]
+ [InlineData("TranscodeMedia", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported | TranscodeReason.DirectPlayError, "DirectStream", "http")]
[InlineData("TranscodeMedia", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.DirectPlayError, "Remux", "http")]
// DirectMedia
[InlineData("DirectMedia", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")]
@@ -150,9 +150,9 @@ namespace Jellyfin.Model.Tests
[InlineData("LowBandwidth", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
[InlineData("LowBandwidth", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
[InlineData("LowBandwidth", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("LowBandwidth", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("LowBandwidth", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("LowBandwidth", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("LowBandwidth", "mkv-vp9-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("LowBandwidth", "mkv-vp9-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("LowBandwidth", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
// Null
[InlineData("Null", "mp4-h264-aac-vtt-2600k", null, TranscodeReason.ContainerBitrateExceedsLimit)]
[InlineData("Null", "mp4-h264-ac3-aac-srt-2600k", null, TranscodeReason.ContainerBitrateExceedsLimit)]
@@ -170,10 +170,10 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)]
- [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9
+ [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9
[InlineData("AndroidTVExoPlayer", "mp4-hevc-aac-4000k-r180", PlayMethod.DirectPlay)] // #13712
// AndroidTV NoHevcRotation
- [InlineData("AndroidTVExoPlayer-NoHevcRotation", "mp4-hevc-aac-4000k-r180", PlayMethod.Transcode, TranscodeReason.VideoRotationNotSupported, "Transcode")] // #13712
+ [InlineData("AndroidTVExoPlayer-NoHevcRotation", "mp4-hevc-aac-4000k-r180", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.VideoRotationNotSupported, "Transcode")] // #13712
// Tizen 3 Stereo
[InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)]
[InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)]
@@ -255,23 +255,23 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidPixel", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450
[InlineData("AndroidPixel", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450
[InlineData("AndroidPixel", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectPlay)] // #6450
- [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
- [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("AndroidPixel", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
+ [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")]
// Yatse
[InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)]
[InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// RokuSSPlus
[InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450 should be DirectPlay
[InlineData("RokuSSPlus", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("RokuSSPlus", "mp4-h264-ac3-srt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported)] // #6450
[InlineData("RokuSSPlus", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
- [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
- [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("RokuSSPlus", "mp4-hevc-ac3-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// JellyfinMediaPlayer
[InlineData("JellyfinMediaPlayer", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("JellyfinMediaPlayer", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
@@ -289,7 +289,7 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidTVExoPlayer", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)]
- [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9
+ [InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9
// Tizen 3 Stereo
[InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)]
[InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)]
@@ -336,7 +336,7 @@ namespace Jellyfin.Model.Tests
// Yatse
[InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450
[InlineData("Yatse", "mp4-h264-ac3-aac-aac-srt-2600k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Remux")]
- [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
+ [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported | TranscodeReason.SecondaryAudioNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow hevc
// RokuSSPlus
[InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
[InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450
diff --git a/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs b/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs
new file mode 100644
index 0000000000..19ee13cd75
--- /dev/null
+++ b/tests/Jellyfin.Naming.Tests/Book/BookResolverTests.cs
@@ -0,0 +1,58 @@
+using Emby.Naming.Book;
+using Xunit;
+
+namespace Jellyfin.Naming.Tests.Book;
+
+public class BookResolverTests
+{
+ [Theory]
+ // seriesName (seriesYear?) #index (of count?) (year?)
+ [InlineData("Sherlock Holmes (1887) #1 (of 4) (1887)", null, "Sherlock Holmes", 1, 1887)]
+ [InlineData("Sherlock Holmes #2", null, "Sherlock Holmes", 2, null)]
+ [InlineData("Sherlock Holmes (1887) #1", null, "Sherlock Holmes", 1, null)]
+ [InlineData("Sherlock Holmes #2 (1890)", null, "Sherlock Holmes", 2, 1890)]
+ // name (seriesName, #index) (year?)
+ [InlineData("A Study in Scarlet (Sherlock Holmes, #1) (1887)", "A Study in Scarlet", "Sherlock Holmes", 1, 1887)]
+ [InlineData("The Adventures of Sherlock Holmes (Sherlock Holmes, #5)", "The Adventures of Sherlock Holmes", "Sherlock Holmes", 5, null)]
+ // name (year)
+ [InlineData("The Sign of the Four (1890)", "The Sign of the Four", null, null, 1890)]
+ [InlineData("The Valley of Fear (1915)", "The Valley of Fear", null, null, 1915)]
+ // index - name (year?)
+ [InlineData("2 - The Sign of the Four (1890)", "The Sign of the Four", null, 2, 1890)]
+ [InlineData("4 - The Valley of Fear", "The Valley of Fear", null, 4, null)]
+ // parse entire string as book name
+ [InlineData("A Study in Scarlet", "A Study in Scarlet", null, null, null)]
+ [InlineData("The Adventures of Sherlock Holmes", "The Adventures of Sherlock Holmes", null, null, null)]
+ // leading zeros on index number
+ [InlineData("00 - Dracula's Guest (1914)", "Dracula's Guest", null, 0, 1914)]
+ [InlineData("01 - Dracula (1897)", "Dracula", null, 1, 1897)]
+ // basic decimal support for prequels and novellas
+ [InlineData("2.0 - Twenty Thousand Leagues Under the Sea", "Twenty Thousand Leagues Under the Sea", null, 2, null)]
+ // TODO decide how to process non-zero decimals
+ [InlineData("2.1 - The Blockade Runners", "2.1 - The Blockade Runners", null, null, null)]
+ public void Resolve_Books(string input, string? name, string? series, int? index, int? year)
+ {
+ var result = BookFileNameParser.Parse(input);
+
+ Assert.Equal(name, result.Name);
+ Assert.Equal(series, result.SeriesName);
+ Assert.Equal(index, result.Index);
+ Assert.Equal(year, result.Year);
+ }
+
+ [Theory]
+ // name volume? chapter? (year?)
+ [InlineData("Captain Marvel Adventures v01 (1941)", "Captain Marvel Adventures v01", null, null, 1, 1941)]
+ [InlineData("Captain Marvel Adventures c120", "Captain Marvel Adventures c120", null, 120, null, null)]
+ [InlineData("Captain Marvel Adventures v01 c120", "Captain Marvel Adventures v01 c120", null, 120, 1, null)]
+ public void Resolve_Comics(string input, string? name, string? series, int? chapter, int? volume, int? year)
+ {
+ var result = BookFileNameParser.Parse(input);
+
+ Assert.Equal(name, result.Name);
+ Assert.Equal(series, result.SeriesName);
+ Assert.Equal(chapter, result.Index);
+ Assert.Equal(volume, result.ParentIndex);
+ Assert.Equal(year, result.Year);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index a5de0a4416..9c247d54b9 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -56,57 +57,63 @@ public class DtoServiceTests
}
[Fact]
- public void GetBaseItemDto_PreferEpisodeParentPoster_PrefersSeasonPosterOverEpisodeAndSeries()
+ public void GetBaseItemDto_Episode_AttachesSeasonPosterAsParentPrimaryImage()
{
- var (episode, season, series) = BuildEpisode(seasonHasPoster: true);
- var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
+ var (episode, season, _) = BuildEpisode(seasonHasPoster: true);
+ var options = new DtoOptions(false) { Fields = [ItemFields.PrimaryImageAspectRatio] };
var dto = _dtoService.GetBaseItemDto(episode, options);
- // The episode's own 16:9 primary is dropped in favor of the season's portrait poster.
- Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
- Assert.Null(dto.SeriesPrimaryImageTag);
+ // The season poster is attached additively; the episode keeps its own primary and 16:9 ratio,
+ // and clients decide per view whether to prefer the parent/series poster over the episode still.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Equal(season.Id, dto.ParentPrimaryImageItemId);
Assert.Equal("tag:" + season.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
- // Aspect ratio follows the (portrait) poster, not the episode's 16:9 image.
- Assert.Equal(season.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
+ // Aspect ratio stays the episode's own image, not the poster's.
+ Assert.Equal(episode.GetDefaultPrimaryImageAspectRatio(), dto.PrimaryImageAspectRatio);
}
[Fact]
- public void GetBaseItemDto_PreferEpisodeParentPoster_FallsBackToSeriesWhenSeasonHasNoPoster()
+ public void GetBaseItemDto_Episode_ParentPrimaryImageFallsBackToSeriesWhenSeasonHasNoPoster()
{
var (episode, _, series) = BuildEpisode(seasonHasPoster: false);
- var options = new DtoOptions(false) { PreferEpisodeParentPoster = true };
+ var options = new DtoOptions(false);
var dto = _dtoService.GetBaseItemDto(episode, options);
- Assert.False(dto.ImageTags is not null && dto.ImageTags.ContainsKey(ImageType.Primary));
- Assert.Null(dto.SeriesPrimaryImageTag);
+ // Episode image is retained; ParentPrimaryImage falls back to the series poster.
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Equal(series.Id, dto.ParentPrimaryImageItemId);
Assert.Equal("tag:" + series.GetImageInfo(ImageType.Primary, 0)!.Path, dto.ParentPrimaryImageTag);
}
[Fact]
- public void GetBaseItemDto_WithoutPreferEpisodeParentPoster_KeepsEpisodePrimary()
+ public void GetBaseItemDto_Episode_WithoutParentPosters_KeepsOnlyEpisodePrimary()
{
- var (episode, _, _) = BuildEpisode(seasonHasPoster: true);
+ var (episode, _, _) = BuildEpisode(seasonHasPoster: false, seriesHasPoster: false);
var options = new DtoOptions(false);
var dto = _dtoService.GetBaseItemDto(episode, options);
- // Default behavior: the episode keeps its own primary and exposes the series poster as a tag.
+ // With no season or series poster there is nothing to attach; the episode keeps its own primary.
Assert.NotNull(dto.ImageTags);
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
- Assert.NotNull(dto.SeriesPrimaryImageTag);
Assert.Null(dto.ParentPrimaryImageItemId);
}
- private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster)
+ private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true)
{
// Non-local (http) paths keep aspect-ratio resolution off the image processor and on the
// item's default ratio, which is portrait (2/3) for Season/Series and 16:9 for Episode.
var series = new Series { Id = Guid.NewGuid(), Name = "Series" };
- series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
+ if (seriesHasPoster)
+ {
+ series.SetImage(new ItemImageInfo { Type = ImageType.Primary, Path = "http://test/series.jpg" }, 0);
+ }
var season = new Season { Id = Guid.NewGuid(), Name = "Season", SeriesId = series.Id };
if (seasonHasPoster)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs
new file mode 100644
index 0000000000..66c392a6ad
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/FullSystemBackup/BackupServiceTests.cs
@@ -0,0 +1,182 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.FullSystemBackup;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.SystemBackupService;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.FullSystemBackup;
+
+/// <summary>
+/// Tests for <see cref="BackupService"/>, in particular that a single row of corrupt
+/// <see cref="KeyframeData"/> (e.g. malformed <c>KeyframeTicks</c> JSON) does not abort
+/// an otherwise healthy backup. See https://github.com/jellyfin/jellyfin/issues/17216.
+/// </summary>
+public sealed class BackupServiceTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly string _testRoot;
+ private readonly string _backupPath;
+ private readonly string _configurationDirectoryPath;
+
+ public BackupServiceTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ // Use the test assembly's own output directory instead of Path.GetTempPath(). On GitHub-hosted
+ // windows-latest runners, the system temp directory lives on the constrained C: drive, which can have
+ // less than the 5GiB BackupService requires free, causing spurious failures. AppContext.BaseDirectory
+ // is under the repo checkout (the much larger D: drive on Windows runners) on all platforms.
+ _testRoot = Path.Combine(AppContext.BaseDirectory, "jellyfin-backup-service-tests-" + Guid.NewGuid().ToString("N"));
+ _backupPath = Path.Combine(_testRoot, "Backup");
+ _configurationDirectoryPath = Path.Combine(_testRoot, "Config");
+ Directory.CreateDirectory(_backupPath);
+ Directory.CreateDirectory(_configurationDirectoryPath);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+
+ if (Directory.Exists(_testRoot))
+ {
+ Directory.Delete(_testRoot, true);
+ }
+ }
+
+ [Fact]
+ public async Task CreateBackupAsync_WithCorruptKeyframeDataRow_SkipsRowAndCompletesBackup()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var validItemId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var corruptItemId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+
+ await using (var ctx = CreateDbContext())
+ {
+ // A healthy item + keyframe row, written the normal way.
+ ctx.BaseItems.Add(CreateMovieEntity(validItemId, "Good Movie"));
+ ctx.BaseItems.Add(CreateMovieEntity(corruptItemId, "Corrupt Movie"));
+ await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
+
+ ctx.KeyframeData.Add(new KeyframeData
+ {
+ ItemId = validItemId,
+ TotalDuration = 60_000,
+ KeyframeTicks = [0, 1000, 2000]
+ });
+ await ctx.SaveChangesAsync(cancellationToken).ConfigureAwait(true);
+
+ // Simulate a corrupted database row: truncated JSON array for KeyframeTicks,
+ // written directly via SQL to bypass EF's normal (well-formed) write path.
+ await ctx.Database.ExecuteSqlInterpolatedAsync(
+ $"INSERT INTO KeyframeData (ItemId, TotalDuration, KeyframeTicks) VALUES ({corruptItemId.ToString()}, {5000L}, {"[1,2,3"})",
+ cancellationToken).ConfigureAwait(true);
+ }
+
+ var backupService = CreateBackupService();
+
+ var manifest = await backupService.CreateBackupAsync(new BackupOptionsDto()).ConfigureAwait(true);
+
+ Assert.True(File.Exists(manifest.Path));
+
+ using var archive = await ZipFile.OpenReadAsync(manifest.Path, cancellationToken).ConfigureAwait(true);
+ var keyframeEntry = archive.GetEntry("Database/KeyframeData.json");
+ Assert.NotNull(keyframeEntry);
+
+ await using var entryStream = await keyframeEntry!.OpenAsync(cancellationToken).ConfigureAwait(true);
+ using var document = await JsonDocument.ParseAsync(entryStream, cancellationToken: cancellationToken).ConfigureAwait(true);
+
+ var rows = document.RootElement.EnumerateArray().ToList();
+
+ // The corrupt row must be skipped, but the valid row must still make it into the backup.
+ var singleRow = Assert.Single(rows);
+ Assert.Equal(validItemId, singleRow.GetProperty("ItemId").GetGuid());
+ }
+
+ private BackupService CreateBackupService()
+ {
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext);
+
+ var applicationHost = new Mock<IServerApplicationHost>();
+ applicationHost.Setup(a => a.ApplicationVersion).Returns(new Version(10, 11, 0));
+
+ var applicationPaths = new Mock<IServerApplicationPaths>();
+ applicationPaths.Setup(a => a.BackupPath).Returns(_backupPath);
+ applicationPaths.Setup(a => a.ConfigurationDirectoryPath).Returns(_configurationDirectoryPath);
+ applicationPaths.Setup(a => a.DataPath).Returns(Path.Combine(_testRoot, "Data"));
+ applicationPaths.Setup(a => a.RootFolderPath).Returns(Path.Combine(_testRoot, "Root"));
+ applicationPaths.Setup(a => a.InternalMetadataPath).Returns(Path.Combine(_testRoot, "Metadata"));
+ applicationPaths.Setup(a => a.DefaultInternalMetadataPath).Returns(Path.Combine(_testRoot, "MetadataDefault"));
+
+ var jellyfinDatabaseProvider = new Mock<IJellyfinDatabaseProvider>();
+ jellyfinDatabaseProvider.Setup(p => p.RunScheduledOptimisation(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
+ jellyfinDatabaseProvider.Setup(p => p.PurgeDatabase(It.IsAny<JellyfinDbContext>(), It.IsAny<System.Collections.Generic.IEnumerable<string>>())).Returns(Task.CompletedTask);
+
+ var applicationLifetime = new Mock<IHostApplicationLifetime>();
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.IsScanRunning).Returns(false);
+
+ return new BackupService(
+ NullLogger<BackupService>.Instance,
+ factory.Object,
+ applicationHost.Object,
+ applicationPaths.Object,
+ jellyfinDatabaseProvider.Object,
+ applicationLifetime.Object,
+ libraryManager.Object);
+ }
+
+ private static BaseItemEntity CreateMovieEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = "Movie",
+ Name = name,
+ PresentationUniqueKey = id.ToString("N"),
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
index c06279af2d..6cadfacce8 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
@@ -10,7 +10,7 @@ using Xunit;
namespace Jellyfin.Server.Implementations.Tests.IO;
-public class ManagedFileSystemTests
+public partial class ManagedFileSystemTests
{
private readonly IFixture _fixture;
private readonly ManagedFileSystem _sut;
@@ -117,7 +117,7 @@ public class ManagedFileSystemTests
}
[SuppressMessage("Naming Rules", "SA1300:ElementMustBeginWithUpperCaseLetter", Justification = "Have to")]
- [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)]
+ [LibraryImport("libc", SetLastError = true)]
[DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)]
- private static extern int symlink(string target, string linkpath);
+ private static partial int symlink([MarshalAs(UnmanagedType.LPStr)] string target, [MarshalAs(UnmanagedType.LPStr)] string linkpath);
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
new file mode 100644
index 0000000000..b7fca74310
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs
@@ -0,0 +1,237 @@
+#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes.
+
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Verifies that the alternate-version-aware query shapes used by the resume filter
+/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
+/// and evaluate correctly on the SQLite provider.
+/// </summary>
+public sealed class AlternateVersionQueryTranslationTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+
+ public AlternateVersionQueryTranslationTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+ }
+
+ [Fact]
+ public void ResumeFilter_VersionProgress_SurfacesPlayedVersion()
+ {
+ Guid userId, primaryId, versionId, otherId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, versionId, otherId) = Seed(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var inProgress = ctx.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ // Scope to the seeded items; EnsureCreated also seeds a placeholder row.
+ var seededIds = new[] { primaryId, versionId, otherId };
+
+ // Mirrors the resumable=true filter in BaseItemRepository.TranslateQuery.
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+ var resumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id))
+ .Where(e => inProgressIds.Contains(e.Id))
+ .Where(e => !ctx.BaseItems
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)))
+ .Select(e => e.Id)
+ .ToList();
+
+ Assert.Equal([versionId], resumable);
+
+ // The not-resumable direction keeps primaries only.
+ var resumableMovieIds = inProgress
+ .Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
+ var notResumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
+ .Where(e => !resumableMovieIds.Contains(e.Id))
+ .Select(e => e.Id)
+ .ToList();
+
+ Assert.Equal([otherId], notResumable);
+ }
+ }
+
+ [Fact]
+ public void ResumeFilter_TiedLastPlayedDate_KeepsSingleVersion()
+ {
+ Guid userId, primaryId, versionAId, versionBId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, versionAId, versionBId) = SeedTiedVersions(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ var inProgress = ctx.UserData
+ .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
+
+ var seededIds = new[] { primaryId, versionAId, versionBId };
+ var inProgressIds = inProgress.Select(ud => ud.ItemId);
+
+ // The exact production dedup, including the Guid.CompareTo tie-break. This asserts the
+ // expression translates on SQLite and that two versions sharing an identical LastPlayedDate
+ // collapse to a single row instead of double-listing the item in Continue Watching.
+ var resumable = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id))
+ .Where(e => inProgressIds.Contains(e.Id))
+ .Where(e => !ctx.BaseItems
+ .Where(s => s.Id != e.Id
+ && inProgressIds.Contains(s.Id)
+ && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
+ .Any(s =>
+ inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ > inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ || (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
+ == inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
+ && s.Id.CompareTo(e.Id) < 0)))
+ .Select(e => e.Id)
+ .ToList();
+
+ var survivor = Assert.Single(resumable);
+ Assert.Contains(survivor, new[] { versionAId, versionBId });
+ }
+ }
+
+ [Fact]
+ public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate()
+ {
+ Guid userId, primaryId, otherId;
+
+ using (var ctx = CreateDbContext())
+ {
+ (userId, primaryId, _, otherId) = Seed(ctx);
+ }
+
+ using (var ctx = CreateDbContext())
+ {
+ // Scope to the seeded items; EnsureCreated also seeds a placeholder row.
+ var seededIds = new[] { primaryId, otherId };
+
+ // Mirrors the DatePlayed mapping in OrderMapper.
+ var ordered = ctx.BaseItems
+ .Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
+ .OrderByDescending(e => ctx.UserData
+ .Where(w => w.UserId == userId && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
+ .Max(f => f.LastPlayedDate))
+ .Select(e => e.Id)
+ .ToList();
+
+ // The movie whose only progress is on its alternate version sorts before the unplayed one.
+ Assert.Equal([primaryId, otherId], ordered);
+ }
+ }
+
+ private static (Guid UserId, Guid PrimaryId, Guid VersionId, Guid OtherId) Seed(JellyfinDbContext ctx)
+ {
+ var user = new User("test", "auth-provider", "reset-provider");
+ ctx.Users.Add(user);
+
+ var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ var version = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ var other = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ ctx.BaseItems.AddRange(primary, version, other);
+
+ // Progress only on the alternate version.
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = version.Id,
+ Item = version,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = version.Id.ToString("N"),
+ PlaybackPositionTicks = 1000,
+ LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)
+ });
+
+ ctx.SaveChanges();
+ return (user.Id, primary.Id, version.Id, other.Id);
+ }
+
+ private static (Guid UserId, Guid PrimaryId, Guid VersionAId, Guid VersionBId) SeedTiedVersions(JellyfinDbContext ctx)
+ {
+ var user = new User("test", "auth-provider", "reset-provider");
+ ctx.Users.Add(user);
+
+ var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
+ var versionA = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ var versionB = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
+ ctx.BaseItems.AddRange(primary, versionA, versionB);
+
+ // Both versions in progress with the exact same LastPlayedDate - the tie that a strict '>' cannot break.
+ var tied = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = versionA.Id,
+ Item = versionA,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = versionA.Id.ToString("N"),
+ PlaybackPositionTicks = 1000,
+ LastPlayedDate = tied
+ });
+ ctx.UserData.Add(new UserData
+ {
+ ItemId = versionB.Id,
+ Item = versionB,
+ UserId = user.Id,
+ User = user,
+ CustomDataKey = versionB.Id.ToString("N"),
+ PlaybackPositionTicks = 2000,
+ LastPlayedDate = tied
+ });
+
+ ctx.SaveChanges();
+ return (user.Id, primary.Id, versionA.Id, versionB.Id);
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
new file mode 100644
index 0000000000..083f725db9
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
@@ -0,0 +1,144 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class BaseItemRepositoryGroupingTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly BaseItemRepository _repository;
+ private readonly string _movieTypeName;
+
+ public BaseItemRepositoryGroupingTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ var itemTypeLookup = new ItemTypeLookup();
+ _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+
+ var serverConfigurationManager = new Mock<IServerConfigurationManager>();
+ serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+
+ _repository = new BaseItemRepository(
+ factory.Object,
+ new Mock<IServerApplicationHost>().Object,
+ itemTypeLookup,
+ serverConfigurationManager.Object,
+ NullLogger<BaseItemRepository>.Instance);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public void GetItemList_VersionGroup_ReturnsPrimaryVersion()
+ {
+ // The alternate version sorts before the primary by id, so a plain Min(Id) per
+ // presentation key would wrongly pick the alternate as the group representative.
+ var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
+ var alternateId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var presentationKey = primaryId.ToString("N");
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie", presentationKey, null));
+ ctx.BaseItems.Add(CreateMovieEntity(alternateId, "Movie - 1080p", presentationKey, primaryId));
+ ctx.SaveChanges();
+ }
+
+ var result = _repository.GetItemList(CreateQuery());
+
+ var item = Assert.Single(result);
+ Assert.Equal(primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItemList_GroupWithoutPrimary_FallsBackToMinId()
+ {
+ var firstId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+ var otherPrimaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ var presentationKey = otherPrimaryId.ToString("N");
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.BaseItems.Add(CreateMovieEntity(firstId, "Movie", presentationKey, otherPrimaryId));
+ ctx.BaseItems.Add(CreateMovieEntity(secondId, "Movie - 4K", presentationKey, otherPrimaryId));
+ ctx.SaveChanges();
+ }
+
+ var result = _repository.GetItemList(CreateQuery());
+
+ var item = Assert.Single(result);
+ Assert.Equal(firstId, item.Id);
+ }
+
+ private static InternalItemsQuery CreateQuery()
+ {
+ // IncludeOwnedItems keeps the alternate version rows in the query so the
+ // grouping collapse is what picks the group representative.
+ return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie],
+ IncludeOwnedItems = true
+ };
+ }
+
+ private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _movieTypeName,
+ Name = name,
+ PresentationUniqueKey = presentationKey,
+ PrimaryVersionId = primaryVersionId,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
index 958ffb8b6e..29de52a2ba 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
+++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
@@ -4,6 +4,7 @@
<PropertyGroup>
<ProjectGuid>{2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE}</ProjectGuid>
<OutputType>Exe</OutputType>
+ <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index facdb2bc2e..c80f899498 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using AutoFixture;
using AutoFixture.AutoMoq;
using Castle.Components.DictionaryAdapter;
@@ -7,6 +9,8 @@ using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -144,5 +148,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library
_mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex);
}
+
+ [Fact]
+ public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+ SetupUserDataBatch(new Dictionary<Guid, UserItemData>
+ {
+ [alt1.Id] = new UserItemData { Key = "alt1", PlaybackPositionTicks = 10, LastPlayedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
+ [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
+ });
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
+
+ // The most recently played version is the default source, so resuming plays the right file.
+ // Per-user positions live in each version's UserData, not on the source.
+ Assert.Equal(alt2.Id.ToString("N"), sources[0].Id);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_AlternateQueried_KeepsOwnSourceFirst()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+ SetupUserDataBatch(new Dictionary<Guid, UserItemData>
+ {
+ [alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
+ });
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user);
+
+ // An explicitly opened version keeps its own source first, even when a sibling was
+ // played more recently.
+ Assert.Equal(alt1.Id.ToString("N"), sources[0].Id);
+ Assert.Equal(3, sources.Count);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_NoProgress_KeepsQueriedItemFirst()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+ SetupUserDataBatch([]);
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
+
+ Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
+ }
+
+ [Fact]
+ public void GetStaticMediaSources_NoUser_DoesNotTouchUserData()
+ {
+ var (primary, _, _) = SetupVersionGroup();
+
+ var sources = _mediaSourceManager.GetStaticMediaSources(primary, false);
+
+ Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
+ _mockUserDataManager.Verify(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()), Times.Never);
+ }
+
+ private void SetupUserDataBatch(Dictionary<Guid, UserItemData> userData)
+ {
+ _mockUserDataManager
+ .Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()))
+ .Returns((IReadOnlyList<BaseItem> items, User _) => items
+ .Where(i => userData.ContainsKey(i.Id))
+ .ToDictionary(i => i.Id, i => userData[i.Id]));
+ }
+
+ private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
+ {
+ var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
+ var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
+ var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
+
+ // BaseItem.GetMediaSources runs against the static service locators.
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())).Returns(new List<MediaStream>());
+ mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>());
+
+ var segmentManager = new Mock<IMediaSegmentManager>();
+ segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false);
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
+ libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
+ libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
+
+ var recordingsManager = new Mock<IRecordingsManager>();
+ recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null);
+
+ BaseItem.MediaSegmentManager = segmentManager.Object;
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+ Video.RecordingsManager = recordingsManager.Object;
+
+ return (primary, alt1, alt2);
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
index 650d67b195..e65bc1d31f 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
@@ -9,44 +9,105 @@ namespace Jellyfin.Server.Implementations.Tests.Library
{
[Theory]
[InlineData("Superman: Red Son [imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [imdbid-tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdb-tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son - tt10985510", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb=tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdb-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid-tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdb-tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son", "imdbid", null)]
[InlineData("Superman: Red Son [imdbid1=tt11111111][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [imdbid1=tt11111111][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son {imdbid1=tt11111111}(imdbid=tt10985510)", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son {imdbid1=tt11111111}(imdb=tt10985510)", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (imdbid1-tt11111111)[imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son (imdbid1-tt11111111)[imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid=618355][imdb=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid-618355]{imdbid-tt10985510}", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdbid-618355]{imdb-tt10985510}", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son (tmdbid-618355)[imdbid-tt10985510]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son (tmdbid-618355)[imdb-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 [provider=99][providerid=5]", "providerid", "5")]
[InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")]
- [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tmdb=3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdbid-3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tmdb-3]", "tmdbid", "3")]
[InlineData("Superman: Red Son {tmdbid=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb=3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdbid-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son {tmdb-3}", "tmdbid", "3")]
+ [InlineData("Superman: Red Son (tmdbid=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb=6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdbid-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son (tmdb-6)", "tmdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb=6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son [tvdb-6]", "tvdbid", "6")]
+ [InlineData("Superman: Red Son {tvdbid=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb=3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdbid-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son {tvdb-3}", "tvdbid", "3")]
+ [InlineData("Superman: Red Son (tvdbid=6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb=6)", "tvdbid", "6")]
[InlineData("Superman: Red Son (tvdbid-6)", "tvdbid", "6")]
+ [InlineData("Superman: Red Son (tvdb-6)", "tvdbid", "6")]
[InlineData("[tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]", "tmdbid", "618355")]
[InlineData("{tmdbid=618355}", "tmdbid", "618355")]
+ [InlineData("{tmdb=618355}", "tmdbid", "618355")]
[InlineData("(tmdbid=618355)", "tmdbid", "618355")]
+ [InlineData("(tmdb=618355)", "tmdbid", "618355")]
[InlineData("[tmdbid-618355]", "tmdbid", "618355")]
+ [InlineData("[tmdb-618355]", "tmdbid", "618355")]
[InlineData("{tmdbid-618355)", "tmdbid", null)]
+ [InlineData("{tmdb-618355)", "tmdbid", null)]
[InlineData("[tmdbid-618355}", "tmdbid", null)]
+ [InlineData("[tmdb-618355}", "tmdbid", null)]
[InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")]
+ [InlineData("tmdbid=111111][tmdb=618355]", "tmdbid", "618355")]
[InlineData("[tmdbid=618355]tmdbid=111111]", "tmdbid", "618355")]
+ [InlineData("[tmdb=618355]tmdbid=111111]", "tmdbid", "618355")]
[InlineData("tmdbid=618355]", "tmdbid", null)]
+ [InlineData("tmdb=618355]", "tmdbid", null)]
[InlineData("[tmdbid=618355", "tmdbid", null)]
+ [InlineData("[tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=618355", "tmdbid", null)]
+ [InlineData("tmdb=618355", "tmdbid", null)]
[InlineData("tmdbid=", "tmdbid", null)]
+ [InlineData("tmdb=", "tmdbid", null)]
[InlineData("tmdbid", "tmdbid", null)]
+ [InlineData("tmdb", "tmdbid", null)]
+ [InlineData("[tmdbid= ][tmdbid=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdbid= ][tmdb=223344]", "tmdbid", "223344")]
+ [InlineData("[tmdb= ][tmdbid=223344]", "tmdbid", "223344")]
[InlineData("[tmdbid=][imdbid=tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb=][imdbid=tt10985510]", "tmdbid", null)]
[InlineData("[tmdbid-][imdbid-tt10985510]", "tmdbid", null)]
+ [InlineData("[tmdb-][imdbid-tt10985510]", "tmdbid", null)]
[InlineData("Superman: Red Son [tmdbid-618355][tmdbid=1234567]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb-618355][tmdbid=1234567]", "tmdbid", "618355")]
[InlineData("{tmdbid=}{imdbid=tt10985510}", "tmdbid", null)]
+ [InlineData("{tmdb=}{imdbid=tt10985510}", "tmdbid", null)]
[InlineData("(tmdbid-)(imdbid-tt10985510)", "tmdbid", null)]
+ [InlineData("(tmdb-)(imdbid-tt10985510)", "tmdbid", null)]
[InlineData("Superman: Red Son {tmdbid-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son {tmdb-618355}{tmdbid=1234567}", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son - tt10985510 [imdbid1=tt11]", "imdbid", "tt10985510")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid1=1]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [tmdb=618355][tmdbid=12345]", "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/Library/UserDataManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
new file mode 100644
index 0000000000..bd14ca008d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/UserDataManagerTests.cs
@@ -0,0 +1,209 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using AudioBook = MediaBrowser.Controller.Entities.AudioBook;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public sealed class UserDataManagerTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserDataManager _userDataManager;
+ private readonly User _user;
+
+ public UserDataManagerTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ var config = new Mock<IServerConfigurationManager>();
+ config.SetupGet(c => c.Configuration).Returns(new ServerConfiguration());
+
+ _userDataManager = new UserDataManager(config.Object, factory.Object);
+ _user = new User("user", "auth-provider", "reset-provider")
+ {
+ Id = Guid.NewGuid()
+ };
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ private AudioBook CreateAudioBook()
+ {
+ // GetUserDataKeys(): ["Author-Series-0001Book Title", "<item id N>"]
+ return new AudioBook
+ {
+ Id = Guid.NewGuid(),
+ Name = "Book Title",
+ Album = "Series",
+ AlbumArtists = new[] { "Author" },
+ IndexNumber = 1
+ };
+ }
+
+ private UserData CreateUserDataRow(AudioBook item, string key, long positionTicks)
+ {
+ return new UserData
+ {
+ ItemId = item.Id,
+ Item = null,
+ UserId = _user.Id,
+ User = null,
+ CustomDataKey = key,
+ PlaybackPositionTicks = positionTicks
+ };
+ }
+
+ [Fact]
+ public void GetUserData_RowsUnderCurrentAndRetiredKeys_PrefersCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ // the retired-key row comes first to ensure selection is by key, not row order
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(currentKey, userData.Key);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoPrimaryKeyRow_UsesNextCurrentKeyRow()
+ {
+ var item = CreateAudioBook();
+ var idKey = item.GetUserDataKeys()[1];
+
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111),
+ CreateUserDataRow(item, idKey, 333)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(idKey, userData.Key);
+ Assert.Equal(333, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_OnlyRetiredKeyRows_ReturnsRetiredKeyRow()
+ {
+ var item = CreateAudioBook();
+
+ item.UserData = new List<UserData>
+ {
+ CreateUserDataRow(item, "Author-Old Album-0001Old File Name", 111)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(111, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_NoRows_ReturnsDefaultWithPrimaryKey()
+ {
+ var item = CreateAudioBook();
+ item.UserData = new List<UserData>();
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(item.GetUserDataKeys()[0], userData.Key);
+ Assert.Equal(0, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserData_RowsForOtherUsers_AreIgnored()
+ {
+ var item = CreateAudioBook();
+ var currentKey = item.GetUserDataKeys()[0];
+
+ var otherUserRow = CreateUserDataRow(item, currentKey, 999);
+ otherUserRow.UserId = Guid.NewGuid();
+
+ item.UserData = new List<UserData>
+ {
+ otherUserRow,
+ CreateUserDataRow(item, currentKey, 222)
+ };
+
+ var userData = _userDataManager.GetUserData(_user, item);
+
+ Assert.NotNull(userData);
+ Assert.Equal(222, userData.PlaybackPositionTicks);
+ }
+
+ [Fact]
+ public void GetUserDataBatch_DatabaseFallback_ResolvesRowsByKeyOrder()
+ {
+ // no preloaded navigation data, so the batch takes the database fallback
+ var fossilItem = CreateAudioBook();
+ var retiredItem = CreateAudioBook();
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Users.Add(_user);
+ ctx.BaseItems.Add(new BaseItemEntity { Id = fossilItem.Id, Type = typeof(AudioBook).FullName! });
+ ctx.BaseItems.Add(new BaseItemEntity { Id = retiredItem.Id, Type = typeof(AudioBook).FullName! });
+
+ // the stale id-key row is inserted first so selection by row order would return it
+ ctx.UserData.AddRange(
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[1], 111),
+ CreateUserDataRow(fossilItem, fossilItem.GetUserDataKeys()[0], 222),
+ CreateUserDataRow(retiredItem, "Author-Old Album-0001Old File Name", 333));
+ ctx.SaveChanges();
+ }
+
+ var result = _userDataManager.GetUserDataBatch([fossilItem, retiredItem], _user);
+
+ Assert.Equal(222, result[fossilItem.Id].PlaybackPositionTicks);
+ Assert.Equal(333, result[retiredItem.Id].PlaybackPositionTicks);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 3b8fe5ca60..bdb726f06d 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -345,6 +345,20 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Fact]
+ public void GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation()
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ UICulture = "en-US"
+ });
+
+ // he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive,
+ // so the region casing has to be preserved or the file is not found and we fall back to en-US.
+ var translated = localizationManager.GetLocalizedString("Books", "he-IL");
+ Assert.Equal("ספרים", translated);
+ }
+
+ [Fact]
public void GetServerLocalizedString_UsesServerCulture()
{
var localizationManager = Setup(new ServerConfiguration
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs
new file mode 100644
index 0000000000..bd9e680cd9
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/GroupTests.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.SyncPlay;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class GroupTests
+{
+ public GroupTests()
+ {
+ var mockLogger = new Mock<ILogger<Emby.Server.Implementations.SyncPlay.Group>>();
+ MockLoggerFactory = new Mock<ILoggerFactory>();
+ MockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny<string>())).Returns(mockLogger.Object);
+
+ MockUserManager = new Mock<IUserManager>();
+ MockSessionManager = new Mock<ISessionManager>();
+ MockLibraryManager = new Mock<ILibraryManager>();
+ MockItem = new Mock<BaseItem>();
+ MockItem.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
+ }
+
+ private Mock<ILoggerFactory> MockLoggerFactory { get; }
+
+ private Mock<IUserManager> MockUserManager { get; }
+
+ private Mock<ISessionManager> MockSessionManager { get; }
+
+ private Mock<ILibraryManager> MockLibraryManager { get; }
+
+ private Mock<BaseItem> MockItem { get; }
+
+ [Fact]
+ public void HasAccessToPlayQueue_ReturnsTrue_WhenItemsAreVisible()
+ {
+ MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(MockItem.Object);
+
+ var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object);
+ var itemId = Guid.NewGuid();
+ var playlist = new List<Guid> { itemId };
+ group.PlayQueue.Reset();
+ group.PlayQueue.SetPlaylist(playlist);
+
+ Assert.Single(group.PlayQueue.GetPlaylist());
+ Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId);
+
+ var user = new User("test-user", "auth-provider", "pwdreset-provider");
+ var result = group.HasAccessToPlayQueue(user);
+
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void HasAccessToPlayQueue_ReturnsFalse_WhenLibraryReturnsNullForItem()
+ {
+ MockLibraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns((BaseItem?)null);
+
+ Assert.Null(MockLibraryManager.Object.GetItemById(Guid.NewGuid()));
+
+ var group = new Emby.Server.Implementations.SyncPlay.Group(MockLoggerFactory.Object, MockUserManager.Object, MockSessionManager.Object, MockLibraryManager.Object);
+ var itemId = Guid.NewGuid();
+ var playlist = new List<Guid> { itemId };
+ group.PlayQueue.Reset();
+ group.PlayQueue.SetPlaylist(playlist);
+
+ Assert.Single(group.PlayQueue.GetPlaylist());
+ Assert.Equal(itemId, group.PlayQueue.GetPlaylist()[0].ItemId);
+
+ var user = new User("test-user", "auth-provider", "pwdreset-provider");
+ var result = group.HasAccessToPlayQueue(user);
+
+ Assert.False(result);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
index 92e10c9f92..4a10b2f607 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs
@@ -109,5 +109,29 @@ namespace Jellyfin.Server.Implementations.Tests.Updates
var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
Assert.Null(ex);
}
+
+ [Theory]
+ [InlineData("../evil")]
+ [InlineData("..\\evil")]
+ [InlineData("../../escape_attempt")]
+ [InlineData("..")]
+ [InlineData(".")]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("foo/bar")]
+ [InlineData("foo\\bar")]
+ [InlineData("/absolute")]
+ [InlineData("foo\0bar")]
+ public async Task InstallPackage_InvalidName_ThrowsInvalidDataException(string name)
+ {
+ var packageInfo = new InstallationInfo()
+ {
+ Name = name,
+ SourceUrl = "https://repo.jellyfin.org/releases/plugin/empty/empty.zip",
+ Checksum = "11b5b2f1a9ebc4f66d6ef19018543361"
+ };
+
+ await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None));
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
new file mode 100644
index 0000000000..cb714a4014
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Cryptography;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users
+{
+ public sealed class UserManagerProfileImageTests : IDisposable
+ {
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly UserManager _userManager;
+
+ public UserManagerProfileImageTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ // Create the schema
+ using var ctx = CreateDbContext();
+ ctx.Database.EnsureCreated();
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var cryptoProvider = new Mock<ICryptoProvider>();
+ var configManager = new Mock<IServerConfigurationManager>();
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath());
+ configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object);
+
+ var appHost = new Mock<IApplicationHost>();
+
+ var defaultAuthProvider = new DefaultAuthenticationProvider(
+ NullLogger<DefaultAuthenticationProvider>.Instance,
+ cryptoProvider.Object);
+ var invalidAuthProvider = new InvalidAuthProvider();
+ var defaultPasswordResetProvider = new DefaultPasswordResetProvider(
+ configManager.Object,
+ appHost.Object);
+
+ _userManager = new UserManager(
+ factory.Object,
+ new NoopEventManager(),
+ new Mock<INetworkManager>().Object,
+ appHost.Object,
+ new Mock<IImageProcessor>().Object,
+ NullLogger<UserManager>.Instance,
+ configManager.Object,
+ new IPasswordResetProvider[] { defaultPasswordResetProvider },
+ new IAuthenticationProvider[] { defaultAuthProvider, invalidAuthProvider });
+ }
+
+ public void Dispose()
+ {
+ _userManager.Dispose();
+ _connection.Dispose();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+
+ [Fact]
+ public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage()
+ {
+ var user = await _userManager.CreateUserAsync("profileimageuser");
+
+ // Assign a profile image the same way the image endpoint does and persist it.
+ // UpdateUserAsync creates the persisted ImageInfo on a separately loaded db entity,
+ // so the in-memory instance below is never assigned the database generated key.
+ user.ProfileImage = new ImageInfo(Path.Combine(Path.GetTempPath(), "profile.png"));
+ await _userManager.UpdateUserAsync(user);
+
+ // Precondition reproducing the bug: the in-memory image still carries the default,
+ // never-persisted (temporary) key, while a real image row exists in the database.
+ Assert.Equal(0, user.ProfileImage.Id);
+ Assert.NotNull(_userManager.GetUserById(user.Id)!.ProfileImage);
+
+ // This used to throw InvalidOperationException:
+ // "The property 'ImageInfo.Id' has a temporary value while attempting to change the entity's state to 'Deleted'."
+ var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
+
+ Assert.Null(exception);
+ Assert.Null(user.ProfileImage);
+ Assert.Null(_userManager.GetUserById(user.Id)!.ProfileImage);
+ }
+
+ [Fact]
+ public async Task ClearProfileImageAsync_WhenNoProfileImage_DoesNothing()
+ {
+ var user = await _userManager.CreateUserAsync("noprofileimageuser");
+
+ var exception = await Record.ExceptionAsync(() => _userManager.ClearProfileImageAsync(user));
+
+ Assert.Null(exception);
+ Assert.Null(user.ProfileImage);
+ }
+
+ private sealed class NoopEventManager : IEventManager
+ {
+ public void Publish<T>(T eventArgs)
+ where T : EventArgs
+ {
+ }
+
+ public Task PublishAsync<T>(T eventArgs)
+ where T : EventArgs
+ => Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
index 4cea53bd3d..2bf1d1d05b 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerTests.cs
@@ -27,6 +27,8 @@ namespace Jellyfin.Server.Implementations.Tests.Users
[InlineData(" thishasaspaceatthestart")]
[InlineData(" thishasaspaceatbothends ")]
[InlineData(" this has a space at both ends and inbetween ")]
+ [InlineData(".")]
+ [InlineData("..")]
public void ThrowIfInvalidUsername_WhenInvalidUsername_ThrowsArgumentException(string username)
{
Assert.Throws<ArgumentException>(() => UserManager.ThrowIfInvalidUsername(username));
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
index 7ea56be731..4e01282dcf 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
@@ -1,6 +1,5 @@
using System;
using System.Globalization;
-using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
@@ -164,5 +163,33 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
using var response = await UpdateUserPassword(client, _testUserId, createRequest);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
+
+ [Fact]
+ [Priority(2)]
+ public async Task UpdateUser_UsernameCaseDifference_Success()
+ {
+ var client = _factory.CreateClient();
+
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken!);
+
+ using var response = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var userDto = await response.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken);
+ Assert.NotNull(userDto);
+
+ userDto.Name = userDto.Name.ToLowerInvariant();
+
+ using var response2 = await client.PostAsJsonAsync($"Users?userId={_testUserId}", userDto, _jsonOptions, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, response2.StatusCode);
+
+ using var response3 = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var newUserDto = await response3.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken);
+ Assert.NotNull(newUserDto);
+ Assert.Equal(userDto.Name, newUserDto.Name);
+
+ // Sanity check, make sure we're testing something
+ Assert.NotEqual(TestUsername, userDto.Name);
+ }
}
}
diff --git a/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs
new file mode 100644
index 0000000000..fa15b33af6
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs
@@ -0,0 +1,140 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Net.WebSockets;
+using System.Reflection;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Session;
+using Jellyfin.Api.Models.SyncPlayDtos;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Controller.Net;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests;
+
+public sealed class SyncPlayLostWebSocketTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+
+ public SyncPlayLostWebSocketTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public async Task LostWebSocket_EndsSession_And_RemovesEmptySyncPlayGroup()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var client = _factory.CreateClient();
+ var accessToken = await AuthHelper.CompleteStartupAsync(client);
+ client.DefaultRequestHeaders.AddAuthHeader(accessToken);
+
+ var wsClient = _factory.Server.CreateWebSocketClient();
+ wsClient.ConfigureRequest = request =>
+ request.Headers.Authorization = AuthHelper.DummyAuthHeader + $", Token={accessToken}";
+
+ var webSocket = await wsClient.ConnectAsync(
+ new UriBuilder(_factory.Server.BaseAddress)
+ {
+ Scheme = "ws",
+ Path = "websocket"
+ }.Uri,
+ cancellationToken);
+
+ _ = DrainAsync(webSocket, cancellationToken);
+
+ var watched = await WaitForWatchedWebSocketsAsync(TimeSpan.FromSeconds(10), cancellationToken);
+ var connection = Assert.Single(watched);
+
+ using var createResponse = await client.PostAsync(
+ "SyncPlay/New",
+ JsonContent.Create(new NewGroupRequestDto { GroupName = "ZombieGroupRepro" }, options: JsonDefaults.Options),
+ cancellationToken);
+ Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
+ Assert.Equal(1, await WaitForGroupCountAsync(client, 1, TimeSpan.FromSeconds(10), cancellationToken));
+
+ connection.LastKeepAliveDate = DateTime.UtcNow - TimeSpan.FromSeconds(180);
+
+ var groupCount = await WaitForGroupCountAsync(client, 0, TimeSpan.FromSeconds(45), cancellationToken);
+ Assert.True(
+ groupCount == 0,
+ $"SyncPlay group still listed {groupCount} group(s) after the WebSocket was lost: "
+ + "the keep-alive watchdog removed the socket from its watchlist without closing "
+ + "the session, leaving a zombie participant in the group (SessionWebSocketListener).");
+ }
+
+ private static async Task DrainAsync(WebSocket webSocket, CancellationToken cancellationToken)
+ {
+ var buffer = new byte[4096];
+ try
+ {
+ while (webSocket.State == WebSocketState.Open)
+ {
+ await webSocket.ReceiveAsync(buffer, cancellationToken);
+ }
+ }
+ catch
+ {
+ // The server tears the connection down once the watchdog gives up on it.
+ }
+ }
+
+ private async Task<IReadOnlyList<IWebSocketConnection>> WaitForWatchedWebSocketsAsync(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var listener = _factory.Services.GetRequiredService<IEnumerable<IWebSocketListener>>()
+ .OfType<SessionWebSocketListener>()
+ .Single();
+ var watchlistField = typeof(SessionWebSocketListener)
+ .GetField("_webSockets", BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(watchlistField);
+ var watchlist = (IEnumerable<IWebSocketConnection>)watchlistField.GetValue(listener)!;
+
+ var stopwatch = Stopwatch.StartNew();
+ while (true)
+ {
+ try
+ {
+ var snapshot = watchlist.ToArray();
+ if (snapshot.Length > 0 || stopwatch.Elapsed >= timeout)
+ {
+ return snapshot;
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // The watchdog mutated the set during enumeration; retry.
+ }
+
+ await Task.Delay(100, cancellationToken);
+ }
+ }
+
+ private static async Task<int> WaitForGroupCountAsync(HttpClient client, int expected, TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var count = -1;
+ while (stopwatch.Elapsed < timeout)
+ {
+ using var response = await client.GetAsync("SyncPlay/List", cancellationToken);
+ response.EnsureSuccessStatusCode();
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
+ count = document.RootElement.GetArrayLength();
+ if (count == expected)
+ {
+ return count;
+ }
+
+ await Task.Delay(500, cancellationToken);
+ }
+
+ return count;
+ }
+}