aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.github/pull_request_template.md6
-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--CONTRIBUTORS.md1
-rw-r--r--Directory.Packages.props57
-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.cs17
-rw-r--r--Emby.Server.Implementations/Devices/DeviceId.cs13
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs66
-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.cs42
-rw-r--r--Emby.Server.Implementations/Library/ExternalDataManager.cs40
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs29
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs101
-rw-r--r--Emby.Server.Implementations/Library/PathManager.cs6
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs8
-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/Search/SearchManager.cs458
-rw-r--r--Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs230
-rw-r--r--Emby.Server.Implementations/Library/SearchEngine.cs200
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs11
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs137
-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/el.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-AR.json4
-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.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/kn.json2
-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/sr.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sw.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json8
-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/ChapterImagesTask.cs3
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs1
-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/HlsSegmentController.cs42
-rw-r--r--Jellyfin.Api/Controllers/ImageController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs9
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs453
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs4
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs2
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs9
-rw-r--r--Jellyfin.Api/Controllers/SearchController.cs15
-rw-r--r--Jellyfin.Api/Controllers/TrailersController.cs1
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs9
-rw-r--r--Jellyfin.Api/Controllers/UniversalAudioController.cs2
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs2
-rw-r--r--Jellyfin.Api/Extensions/DtoExtensions.cs32
-rw-r--r--Jellyfin.Api/Helpers/MediaInfoHelper.cs13
-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.cs15
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs11
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs94
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs8
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs11
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs9
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs175
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs213
-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/20260113120000_MigrateLinkedChildren.cs29
-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/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs182
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs (renamed from Jellyfin.Server/Migrations/Routines/20251008120000_RefreshCleanNames.cs)85
-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/Dto/DtoOptions.cs7
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs174
-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/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/IO/IExternalDataManager.cs7
-rw-r--r--MediaBrowser.Controller/Library/IExternalSearchProvider.cs20
-rw-r--r--MediaBrowser.Controller/Library/IInternalSearchProvider.cs8
-rw-r--r--MediaBrowser.Controller/Library/ISearchEngine.cs18
-rw-r--r--MediaBrowser.Controller/Library/ISearchManager.cs48
-rw-r--r--MediaBrowser.Controller/Library/ISearchProvider.cs44
-rw-r--r--MediaBrowser.Controller/Library/IUserDataManager.cs26
-rw-r--r--MediaBrowser.Controller/Library/SearchProviderQuery.cs45
-rw-r--r--MediaBrowser.Controller/Library/SearchResult.cs60
-rw-r--r--MediaBrowser.Controller/Library/VersionPlaybackSelector.cs59
-rw-r--r--MediaBrowser.Controller/Library/VersionResumeData.cs41
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs160
-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/Encoder/ApplePlatformHelper.cs47
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs3
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs21
-rw-r--r--MediaBrowser.MediaEncoding/MediaBrowser.MediaEncoding.csproj1
-rw-r--r--MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs8
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs112
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs347
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs1
-rw-r--r--MediaBrowser.Model/Configuration/MetadataPluginType.cs3
-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/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/ComicBookInfo/ComicBookInfoProvider.cs238
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs21
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs27
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs107
-rw-r--r--MediaBrowser.Providers/Books/ComicImageProvider.cs158
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs235
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs99
-rw-r--r--MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs120
-rw-r--r--MediaBrowser.Providers/Books/ComicProvider.cs59
-rw-r--r--MediaBrowser.Providers/Books/IComicProvider.cs28
-rw-r--r--MediaBrowser.Providers/Books/OpenPackagingFormat/OpfReader.cs28
-rw-r--r--MediaBrowser.Providers/Manager/ImageSaver.cs2
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs21
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj1
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs2
-rw-r--r--MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs3
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs6
-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.cs109
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs91
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelManager.cs4
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs14
-rw-r--r--src/Jellyfin.LiveTv/IO/EncodedRecorder.cs1
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs4
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs5
-rw-r--r--src/Jellyfin.LiveTv/LiveTvChannelImageHelper.cs33
-rw-r--r--src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs1
-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.Api.Tests/Helpers/MediaInfoHelperTests.cs99
-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.LiveTv.Tests/LiveTvChannelImageHelperTests.cs51
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Encoder/ApplePlatformHelperTests.cs22
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs110
-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/DtoServiceImageInheritanceTests.cs137
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs43
-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/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/UserManagerLockHelperTests.cs93
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs29
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/SyncPlayLostWebSocketTests.cs140
234 files changed, 8979 insertions, 1595 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 5fc7834fcc..bc574d7054 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "10.0.8",
+ "version": "10.0.10",
"commands": [
"dotnet-ef"
]
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index dc93d2c84e..d6833ea2be 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,11 +1,15 @@
<!--
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
-For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our documentation.
+For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
-->
**Changes**
<!-- Describe your changes here in 1-5 sentences. -->
+**Code assistance**
+<!-- If code assistance was used, describe how it contributed
+e.g., code generated by LLM, explanation of code base, debugging guidance. -->
+
**Issues**
<!-- Tag any issues that this PR solves here.
ex. Fixes # -->
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 06a66bab53..bc7ef7f5da 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.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'
- 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..f2c0a5c4cd 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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..4531c824ce 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - 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..f8704fb1eb 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- - 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..6a0d7caa2e 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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..9dec170d70 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.14'
cache: 'pip'
diff --git a/.github/workflows/openapi-generate.yml b/.github/workflows/openapi-generate.yml
index 122bbd69ac..09ab727c15 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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..6b5d035420 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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..d22e8bf6bd 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ env.TAG_BRANCH }}
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index d70ffddfd7..4e323e332a 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -90,6 +90,7 @@
- [mark-monteiro](https://github.com/mark-monteiro)
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
- [Martin Reuter](https://github.com/reuterma24)
+ - [Matt Teahan](https://github.com/matt-teahan)
- [Matt07211](https://github.com/Matt07211)
- [Matthew Jones](https://github.com/matthew-jones-uk)
- [Maxr1998](https://github.com/Maxr1998)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1c26dd34e8..cb02433c06 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -26,28 +26,28 @@
<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.8" />
- <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
- <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.8" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
- <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" />
- <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" />
@@ -57,26 +57,27 @@
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
- <PackageVersion Include="Polly" Version="8.6.6" />
+ <PackageVersion Include="Polly" Version="8.7.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
- <PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
+ <PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
- <PackageVersion Include="SharpFuzz" Version="2.2.0" />
+ <PackageVersion Include="SharpCompress" Version="0.49.1" />
+ <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.0" />
- <PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.0" />
- <PackageVersion Include="System.Text.Json" Version="10.0.8" />
+ <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 c81829688f..69e23bcb63 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -26,6 +26,7 @@ using Emby.Server.Implementations.Dto;
using Emby.Server.Implementations.HttpServer.Security;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library;
+using Emby.Server.Implementations.Library.Search;
using Emby.Server.Implementations.Library.SimilarItems;
using Emby.Server.Implementations.Localization;
using Emby.Server.Implementations.Playlists;
@@ -92,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;
@@ -495,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>();
@@ -539,6 +551,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
+ serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
serviceCollection.AddSingleton<NamingOptions>();
serviceCollection.AddSingleton<VideoListResolver>();
@@ -550,7 +563,8 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
- serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
+ serviceCollection.AddSingleton<ISearchManager, SearchManager>();
+ serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
@@ -709,6 +723,7 @@ namespace Emby.Server.Implementations
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
+ Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
}
/// <summary>
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 f53328c7dd..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);
}
}
@@ -1539,6 +1542,21 @@ namespace Emby.Server.Implementations.Dto
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
{
+ if (item is UserView { ViewType: CollectionType.playlists } playlistsView
+ && options.GetImageLimit(ImageType.Primary) > 0
+ && !playlistsView.DisplayParentId.IsEmpty())
+ {
+ var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId);
+ var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0);
+
+ if (displayParentPrimaryImage is not null)
+ {
+ dto.ImageTags?.Remove(ImageType.Primary);
+ dto.ParentPrimaryImageItemId = displayParent!.Id;
+ dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage);
+ }
+ }
+
if (!item.SupportsInheritedParentImages)
{
return;
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..b701e7eb6d 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,38 +28,7 @@ namespace Emby.Server.Implementations.Images
{
var view = (CollectionFolder)item;
var viewType = view.CollectionType;
-
- BaseItemKind[] includeItemTypes;
-
- switch (viewType)
- {
- 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;
- }
-
+ var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
var recursive = viewType != CollectionType.playlists;
return view.GetItemList(new InternalItemsQuery
@@ -67,12 +36,9 @@ namespace Emby.Server.Implementations.Images
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/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs
index 4ad0f999bf..2c18e56df7 100644
--- a/Emby.Server.Implementations/Library/ExternalDataManager.cs
+++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs
@@ -1,6 +1,5 @@
using System;
using System.IO;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Chapters;
@@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager
/// <inheritdoc/>
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
{
- var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
- var itemId = item.Id;
- if (validPaths.Count > 0)
- {
- foreach (var path in validPaths)
- {
- try
- {
- Directory.Delete(path, true);
- }
- catch (Exception ex)
- {
- _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
- }
- }
- }
+ DeleteExternalItemFiles(item);
+ var itemId = item.Id;
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
}
+
+ /// <inheritdoc/>
+ public void DeleteExternalItemFiles(BaseItem item)
+ {
+ foreach (var path in _pathManager.GetExtractedDataPaths(item))
+ {
+ if (!Directory.Exists(path))
+ {
+ continue;
+ }
+
+ try
+ {
+ Directory.Delete(path, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
+ }
+ }
+ }
}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index ffc449d974..3691f4e19d 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -89,6 +89,7 @@ namespace Emby.Server.Implementations.Library
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
private readonly IMediaStreamRepository _mediaStreamRepository;
+ private readonly Lazy<IExternalDataManager> _externalDataManagerFactory;
/// <summary>
/// The _root folder sync lock.
@@ -132,6 +133,7 @@ namespace Emby.Server.Implementations.Library
/// <param name="pathManager">The path manager.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
/// <param name="mediaStreamRepository">The media stream repository.</param>
+ /// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
public LibraryManager(
IServerApplicationHost appHost,
ILoggerFactory loggerFactory,
@@ -155,7 +157,8 @@ namespace Emby.Server.Implementations.Library
IPeopleRepository peopleRepository,
IPathManager pathManager,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
- IMediaStreamRepository mediaStreamRepository)
+ IMediaStreamRepository mediaStreamRepository,
+ Lazy<IExternalDataManager> externalDataManagerFactory)
{
_appHost = appHost;
_logger = loggerFactory.CreateLogger<LibraryManager>();
@@ -186,6 +189,7 @@ namespace Emby.Server.Implementations.Library
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
_mediaStreamRepository = mediaStreamRepository;
+ _externalDataManagerFactory = externalDataManagerFactory;
RecordConfigurationValues(_configurationManager.Configuration);
}
@@ -396,6 +400,12 @@ namespace Emby.Server.Implementations.Library
}
}
+ var externalDataManager = _externalDataManagerFactory.Value;
+ foreach (var (item, _, _) in pathMaps)
+ {
+ externalDataManager.DeleteExternalItemFiles(item);
+ }
+
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
}
@@ -576,6 +586,13 @@ namespace Emby.Server.Implementations.Library
item.SetParent(null);
+ var externalDataManager = _externalDataManagerFactory.Value;
+ externalDataManager.DeleteExternalItemFiles(item);
+ foreach (var child in children)
+ {
+ externalDataManager.DeleteExternalItemFiles(child);
+ }
+
_persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]);
_cache.TryRemove(item.Id, out _);
foreach (var child in children)
@@ -2433,8 +2450,14 @@ namespace Emby.Server.Implementations.Library
var outdated = forceUpdate
? item.ImageInfos.Where(i => i.Path is not null).ToArray()
: item.ImageInfos.Where(ImageNeedsRefresh).ToArray();
- // Skip image processing if current or live tv source
- if (outdated.Length == 0 || item.SourceType != SourceType.Library)
+
+ var parentItem = item.GetParent();
+ var isLiveTvShow = item.SourceType != SourceType.Library &&
+ parentItem is not null &&
+ parentItem.SourceType != SourceType.Library; // not a channel
+
+ // Skip image processing if current or live tv show
+ if (outdated.Length == 0 || isLiveTvShow)
{
RegisterItem(item);
return;
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 9ccfefa86e..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).ToArray();
+ var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId)
+ ? topSourceId
+ : item.Id;
+
+ return SortMediaSources(list, preferredId).ToArray();
}
/// <inheritdoc />>
@@ -386,6 +390,12 @@ namespace Emby.Server.Implementations.Library
if (user is not null)
{
+ sources = sources
+ .Where(source => !Guid.TryParse(source.Id, out var sourceId)
+ || sourceId.Equals(item.Id)
+ || _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null)
+ .ToArray();
+
foreach (var source in sources)
{
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
@@ -400,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;
@@ -540,24 +603,32 @@ namespace Emby.Server.Implementations.Library
}
}
- private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
+ private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default)
{
- return sources.OrderBy(i =>
- {
- if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ // The source belonging to the queried item sorts first so it stays the default that gets played.
+ var preferredId = preferredItemId.IsEmpty()
+ ? null
+ : preferredItemId.ToString("N", CultureInfo.InvariantCulture);
+
+ return sources
+ .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i =>
{
- return 0;
- }
+ if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ {
+ return 0;
+ }
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i =>
- {
- var stream = i.VideoStream;
+ return 1;
+ })
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i =>
+ {
+ var stream = i.VideoStream;
- return stream?.Width ?? 0;
- })
- .Where(i => i.Type != MediaSourceType.Placeholder);
+ return stream?.Width ?? 0;
+ })
+ .Where(i => i.Type != MediaSourceType.Placeholder);
}
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs
index ef5edb9afa..fad948ad97 100644
--- a/Emby.Server.Implementations/Library/PathManager.cs
+++ b/Emby.Server.Implementations/Library/PathManager.cs
@@ -121,7 +121,11 @@ public class PathManager : IPathManager
}
paths.Add(GetTrickplayDirectory(item, false));
- paths.Add(GetTrickplayDirectory(item, true));
+ if (!string.IsNullOrEmpty(item.Path))
+ {
+ paths.Add(GetTrickplayDirectory(item, true));
+ }
+
paths.Add(GetChapterImageFolderPath(item));
return paths;
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/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/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs
new file mode 100644
index 0000000000..a5be3f07bd
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs
@@ -0,0 +1,458 @@
+using System;
+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 Jellyfin.Extensions;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Search;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.Library.Search;
+
+/// <summary>
+/// Manages search providers and orchestrates search operations.
+/// </summary>
+public class SearchManager : ISearchManager
+{
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IItemQueryHelpers _queryHelpers;
+ private readonly ILogger<SearchManager> _logger;
+ private IExternalSearchProvider[] _externalProviders = [];
+ private IInternalSearchProvider[] _internalProviders = [];
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SearchManager"/> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="userManager">The user manager.</param>
+ /// <param name="dbProvider">The database context factory.</param>
+ /// <param name="queryHelpers">The shared item query helpers.</param>
+ /// <param name="logger">The logger.</param>
+ public SearchManager(
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IItemQueryHelpers queryHelpers,
+ ILogger<SearchManager> logger)
+ {
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _dbProvider = dbProvider;
+ _queryHelpers = queryHelpers;
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public void AddParts(IEnumerable<ISearchProvider> providers)
+ {
+ var allProviders = providers.OrderBy(p => p.Priority).ToArray();
+
+ _externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray();
+ _internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray();
+
+ _logger.LogInformation(
+ "Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}",
+ _externalProviders.Length,
+ string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")),
+ string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})")));
+ }
+
+ /// <inheritdoc/>
+ public IReadOnlyList<ISearchProvider> GetProviders()
+ {
+ return [.. _externalProviders, .. _internalProviders];
+ }
+
+ /// <inheritdoc/>
+ public async Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
+ SearchProviderQuery query,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
+
+ var searchTerm = query.SearchTerm.Trim().RemoveDiacritics();
+
+ var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken);
+ var internalTask = _internalProviders.Length > 0
+ ? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken)
+ : Task.FromResult<IReadOnlyList<SearchResult>>([]);
+
+ await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
+
+ var externalResults = await externalTask.ConfigureAwait(false);
+ var fromExternal = externalResults.Count > 0;
+ IReadOnlyList<SearchResult> results;
+ if (fromExternal)
+ {
+ results = externalResults;
+ }
+ else
+ {
+ results = await internalTask.ConfigureAwait(false);
+ if (_internalProviders.Length > 0)
+ {
+ _logger.LogDebug("No results from external providers, using internal provider results");
+ }
+ }
+
+ // Internal providers apply user-access filtering inline in their queries. External
+ // providers don't know about user permissions, so they may return IDs from hidden
+ // libraries or items the user is otherwise blocked from. Run the post-filter only
+ // when results came from externals to close that gap. The Items controller's second
+ // roundtrip via folder.GetItems applies most of these again, but it does not restrict
+ // by TopParentIds when ItemIds is set.
+ if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
+ {
+ var user = _userManager.GetUserById(query.UserId.Value);
+ if (user is not null)
+ {
+ results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ return results;
+ }
+
+ private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
+ IReadOnlyList<SearchResult> candidates,
+ User user,
+ CancellationToken cancellationToken)
+ {
+ // SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
+ // TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
+ // because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
+ var accessFilter = new InternalItemsQuery(user);
+ _libraryManager.ConfigureUserAccess(accessFilter, user);
+
+ Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
+
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var baseQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .WhereOneOrMany(candidateIds, e => e.Id);
+
+ baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
+
+ var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
+ if (allowedCount == candidates.Count)
+ {
+ return candidates;
+ }
+
+ var allowedIds = await baseQuery
+ .Select(e => e.Id)
+ .ToHashSetAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
+ if (filtered.Count < candidates.Count)
+ {
+ _logger.LogDebug(
+ "Dropped {Dropped} of {Total} search candidates due to user access filtering",
+ candidates.Count - filtered.Count,
+ candidates.Count);
+ }
+
+ return filtered;
+ }
+ }
+
+ /// <inheritdoc/>
+ public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
+
+ var providerQuery = BuildProviderQuery(query);
+ var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false);
+ if (candidates.Count == 0)
+ {
+ return new QueryResult<SearchHintInfo>();
+ }
+
+ var candidateScores = BuildScoreLookup(candidates);
+ var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId);
+
+ var excludeItemTypes = BuildExcludeItemTypes(query);
+ var includeItemTypes = BuildIncludeItemTypes(query);
+
+ var internalQuery = new InternalItemsQuery(user)
+ {
+ ItemIds = candidateScores.Keys.ToArray(),
+ ExcludeItemTypes = excludeItemTypes.ToArray(),
+ IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [],
+ MediaTypes = query.MediaTypes.ToArray(),
+ IncludeItemsByName = !query.ParentId.HasValue,
+ ParentId = query.ParentId ?? Guid.Empty,
+ Recursive = true,
+ IsKids = query.IsKids,
+ IsMovie = query.IsMovie,
+ IsNews = query.IsNews,
+ IsSeries = query.IsSeries,
+ IsSports = query.IsSports,
+ DtoOptions = new DtoOptions
+ {
+ Fields =
+ [
+ ItemFields.AirTime,
+ ItemFields.DateCreated,
+ ItemFields.ChannelInfo,
+ ItemFields.ParentId
+ ]
+ }
+ };
+
+ // MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name
+ // rather than being stored as regular library items. They require special handling:
+ // 1. Convert ParentId to AncestorIds (to filter by library folder)
+ // 2. Set IncludeItemsByName = true (to include these virtual items in results)
+ // 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally)
+ // 4. Use GetAllArtists() instead of GetItemList() to query the artist index
+ IReadOnlyList<BaseItem> items;
+ if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
+ {
+ if (!internalQuery.ParentId.IsEmpty())
+ {
+ internalQuery.AncestorIds = [internalQuery.ParentId];
+ internalQuery.ParentId = Guid.Empty;
+ }
+
+ internalQuery.IncludeItemsByName = true;
+ internalQuery.IncludeItemTypes = [];
+ items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList();
+ }
+ else
+ {
+ items = _libraryManager.GetItemList(internalQuery);
+ }
+
+ var orderedResults = items
+ .Select(item => new SearchHintInfo { Item = item })
+ .OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f))
+ .ToList();
+
+ var totalCount = orderedResults.Count;
+
+ if (query.StartIndex.HasValue)
+ {
+ orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList();
+ }
+
+ if (query.Limit.HasValue)
+ {
+ orderedResults = orderedResults.Take(query.Limit.Value).ToList();
+ }
+
+ return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults);
+ }
+
+ private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync(
+ IEnumerable<ISearchProvider> providers,
+ SearchProviderQuery providerQuery,
+ string searchTerm,
+ CancellationToken cancellationToken)
+ {
+ var requestedLimit = providerQuery.Limit ?? 100;
+ var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray();
+ if (applicable.Length == 0)
+ {
+ return [];
+ }
+
+ var perProvider = await Task.WhenAll(
+ applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken)))
+ .ConfigureAwait(false);
+
+ var bestScores = new Dictionary<Guid, float>();
+ foreach (var providerResults in perProvider)
+ {
+ foreach (var result in providerResults)
+ {
+ UpdateBestScore(bestScores, result);
+ }
+ }
+
+ return bestScores
+ .Select(kvp => new SearchResult(kvp.Key, kvp.Value))
+ .OrderByDescending(r => r.Score)
+ .Take(requestedLimit)
+ .ToList();
+ }
+
+ private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync(
+ ISearchProvider provider,
+ SearchProviderQuery providerQuery,
+ string searchTerm,
+ int requestedLimit,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var results = provider is IExternalSearchProvider externalProvider
+ ? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false)
+ : await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
+
+ _logger.LogDebug(
+ "Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
+ provider.Name,
+ results.Count,
+ searchTerm);
+ return results;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm);
+ return [];
+ }
+ }
+
+ private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync(
+ IExternalSearchProvider provider,
+ SearchProviderQuery providerQuery,
+ int requestedLimit,
+ CancellationToken cancellationToken)
+ {
+ var results = new List<SearchResult>();
+ await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
+ {
+ results.Add(result);
+ if (results.Count >= requestedLimit)
+ {
+ break;
+ }
+ }
+
+ return results;
+ }
+
+ private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result)
+ {
+ if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore)
+ {
+ bestScores[result.ItemId] = result.Score;
+ }
+ }
+
+ private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results)
+ {
+ var lookup = new Dictionary<Guid, float>(results.Count);
+ foreach (var result in results)
+ {
+ lookup[result.ItemId] = result.Score;
+ }
+
+ return lookup;
+ }
+
+ private static SearchProviderQuery BuildProviderQuery(SearchQuery query)
+ {
+ var excludeItemTypes = BuildExcludeItemTypes(query);
+ var includeItemTypes = BuildIncludeItemTypes(query);
+
+ // Remove any excluded types from includes
+ if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0)
+ {
+ includeItemTypes.RemoveAll(excludeItemTypes.Contains);
+ }
+
+ return new SearchProviderQuery
+ {
+ SearchTerm = query.SearchTerm,
+ UserId = query.UserId.IsEmpty() ? null : query.UserId,
+ IncludeItemTypes = includeItemTypes.ToArray(),
+ ExcludeItemTypes = excludeItemTypes.ToArray(),
+ MediaTypes = query.MediaTypes.ToArray(),
+ Limit = query.Limit,
+ ParentId = query.ParentId
+ };
+ }
+
+ private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query)
+ {
+ var excludeItemTypes = query.ExcludeItemTypes.ToList();
+
+ excludeItemTypes.Add(BaseItemKind.Year);
+ excludeItemTypes.Add(BaseItemKind.Folder);
+ excludeItemTypes.Add(BaseItemKind.CollectionFolder);
+
+ if (!query.IncludeGenres)
+ {
+ AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
+ AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
+ }
+
+ if (!query.IncludePeople)
+ {
+ AddIfMissing(excludeItemTypes, BaseItemKind.Person);
+ }
+
+ if (!query.IncludeStudios)
+ {
+ AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
+ }
+
+ if (!query.IncludeArtists)
+ {
+ AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
+ }
+
+ return excludeItemTypes;
+ }
+
+ private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query)
+ {
+ var includeItemTypes = query.IncludeItemTypes.ToList();
+ if (query.IncludeMedia)
+ {
+ return includeItemTypes;
+ }
+
+ if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre))
+ {
+ AddIfMissing(includeItemTypes, BaseItemKind.Genre);
+ AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
+ }
+
+ if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person))
+ {
+ AddIfMissing(includeItemTypes, BaseItemKind.Person);
+ }
+
+ if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio))
+ {
+ AddIfMissing(includeItemTypes, BaseItemKind.Studio);
+ }
+
+ if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist))
+ {
+ AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
+ }
+
+ return includeItemTypes;
+ }
+
+ private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value)
+ => list.Count == 0 || list.Contains(value);
+
+ private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
+ {
+ if (!list.Contains(value))
+ {
+ list.Add(value);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
new file mode 100644
index 0000000000..bc766f1c8c
--- /dev/null
+++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
@@ -0,0 +1,230 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
+
+using System;
+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 Jellyfin.Extensions;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+
+namespace Emby.Server.Implementations.Library.Search;
+
+/// <summary>
+/// Built-in SQL-based search provider that queries the library database directly.
+/// </summary>
+public class SqlSearchProvider : IInternalSearchProvider
+{
+ private const int DefaultSearchLimit = 100;
+ private const float ExactMatchScore = 100f;
+ private const float PrefixMatchScore = 80f;
+ private const float WordPrefixMatchScore = 75f;
+ private const float ContainsMatchScore = 50f;
+
+ private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
+
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IItemTypeLookup _itemTypeLookup;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IItemQueryHelpers _queryHelpers;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SqlSearchProvider"/> class.
+ /// </summary>
+ /// <param name="dbProvider">The database context factory.</param>
+ /// <param name="itemTypeLookup">The item type lookup.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="userManager">The user manager.</param>
+ /// <param name="queryHelpers">The shared item query helpers.</param>
+ public SqlSearchProvider(
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IItemTypeLookup itemTypeLookup,
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IItemQueryHelpers queryHelpers)
+ {
+ _dbProvider = dbProvider;
+ _itemTypeLookup = itemTypeLookup;
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _queryHelpers = queryHelpers;
+ }
+
+ /// <inheritdoc/>
+ public string Name => "Database";
+
+ /// <inheritdoc/>
+ public MetadataPluginType Type => MetadataPluginType.SearchProvider;
+
+ /// <inheritdoc/>
+ public int Priority => 100; // Low priority - runs as fallback
+
+ /// <inheritdoc/>
+ public bool CanSearch(SearchProviderQuery query)
+ {
+ // SQL search can always handle any query
+ return true;
+ }
+
+ /// <inheritdoc/>
+ public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+ ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
+
+ var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics();
+ if (string.IsNullOrEmpty(rawSearchTerm))
+ {
+ return [];
+ }
+
+ var cleanSearchTerm = rawSearchTerm.GetCleanValue();
+ if (string.IsNullOrEmpty(cleanSearchTerm))
+ {
+ return [];
+ }
+
+ var cleanPrefix = cleanSearchTerm + " ";
+ // OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName,
+ // so match it via a case-insensitive LIKE rather than a per-row case conversion
+ // that may not translate to SQL on every provider.
+ var likeOriginal = $"%{rawSearchTerm}%";
+ var limit = query.Limit ?? DefaultSearchLimit;
+
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ // Lightweight projection: select only what's needed to score and identify items.
+ var dbQuery = dbContext.BaseItems
+ .AsNoTracking()
+ .Where(e => e.Id != _placeholderId)
+ .Where(e => !e.IsVirtualItem)
+ .Where(e => e.CleanName!.Contains(cleanSearchTerm)
+ || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));
+
+ dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
+ dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
+ dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
+ dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
+
+ // Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
+ // the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
+ // directly without any per-row case conversion. Items that match only via
+ // OriginalTitle fall through to the Contains tier.
+ // Tie-break by Id for deterministic ordering so the explicit OrderBy + Take
+ // satisfies EF Core's row-limiting-with-OrderBy requirement.
+ var scored = dbQuery.Select(e => new
+ {
+ e.Id,
+ Score =
+ (e.CleanName == cleanSearchTerm) ? ExactMatchScore
+ : e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore
+ : e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore
+ : ContainsMatchScore
+ });
+
+ return await scored
+ .OrderByDescending(x => x.Score)
+ .ThenBy(x => x.Id)
+ .Take(limit)
+ .Select(x => new SearchResult(x.Id, x.Score))
+ .ToArrayAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ private IQueryable<BaseItemEntity> ApplyTypeFilter(
+ IQueryable<BaseItemEntity> query,
+ BaseItemKind[] includeItemTypes,
+ BaseItemKind[] excludeItemTypes)
+ {
+ if (includeItemTypes.Length > 0)
+ {
+ var includeTypeNames = MapKindsToTypeNames(includeItemTypes);
+ if (includeTypeNames.Count > 0)
+ {
+ query = query.Where(e => includeTypeNames.Contains(e.Type));
+ }
+ }
+ else if (excludeItemTypes.Length > 0)
+ {
+ var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes);
+ if (excludeTypeNames.Count > 0)
+ {
+ query = query.Where(e => !excludeTypeNames.Contains(e.Type));
+ }
+ }
+
+ return query;
+ }
+
+ private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter(
+ IQueryable<BaseItemEntity> query,
+ MediaType[] mediaTypes)
+ {
+ if (mediaTypes.Length == 0)
+ {
+ return query;
+ }
+
+ var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray();
+ return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType));
+ }
+
+ private static IQueryable<BaseItemEntity> ApplyParentFilter(
+ IQueryable<BaseItemEntity> query,
+ Guid? parentId)
+ {
+ if (!parentId.HasValue || parentId.Value.IsEmpty())
+ {
+ return query;
+ }
+
+ var pid = parentId.Value;
+ return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
+ }
+
+ private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
+ JellyfinDbContext dbContext,
+ IQueryable<BaseItemEntity> query,
+ Guid? userId)
+ {
+ if (!userId.HasValue || userId.Value.IsEmpty())
+ {
+ return query;
+ }
+
+ var user = _userManager.GetUserById(userId.Value);
+ if (user is null)
+ {
+ return query;
+ }
+
+ var accessFilter = new InternalItemsQuery(user);
+ _libraryManager.ConfigureUserAccess(accessFilter, user);
+ return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
+ }
+
+ private List<string> MapKindsToTypeNames(BaseItemKind[] kinds)
+ {
+ var list = new List<string>(kinds.Length);
+ foreach (var kind in kinds)
+ {
+ if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null)
+ {
+ list.Add(name);
+ }
+ }
+
+ return list;
+ }
+}
diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs
deleted file mode 100644
index c682118597..0000000000
--- a/Emby.Server.Implementations/Library/SearchEngine.cs
+++ /dev/null
@@ -1,200 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Jellyfin.Data.Enums;
-using Jellyfin.Database.Implementations.Entities;
-using Jellyfin.Database.Implementations.Enums;
-using Jellyfin.Extensions;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Search;
-
-namespace Emby.Server.Implementations.Library
-{
- public class SearchEngine : ISearchEngine
- {
- private readonly ILibraryManager _libraryManager;
- private readonly IUserManager _userManager;
-
- public SearchEngine(ILibraryManager libraryManager, IUserManager userManager)
- {
- _libraryManager = libraryManager;
- _userManager = userManager;
- }
-
- public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query)
- {
- User? user = null;
- if (!query.UserId.IsEmpty())
- {
- user = _userManager.GetUserById(query.UserId);
- }
-
- var results = GetSearchHints(query, user);
- var totalRecordCount = results.Count;
-
- if (query.StartIndex.HasValue)
- {
- results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value);
- }
-
- if (query.Limit.HasValue && query.Limit.Value > 0)
- {
- results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count));
- }
-
- return new QueryResult<SearchHintInfo>(
- query.StartIndex,
- totalRecordCount,
- results);
- }
-
- private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
- {
- if (!list.Contains(value))
- {
- list.Add(value);
- }
- }
-
- /// <summary>
- /// Gets the search hints.
- /// </summary>
- /// <param name="query">The query.</param>
- /// <param name="user">The user.</param>
- /// <returns>IEnumerable{SearchHintResult}.</returns>
- /// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception>
- private List<SearchHintInfo> GetSearchHints(SearchQuery query, User? user)
- {
- var searchTerm = query.SearchTerm;
-
- ArgumentException.ThrowIfNullOrEmpty(searchTerm);
-
- searchTerm = searchTerm.Trim().RemoveDiacritics();
-
- var excludeItemTypes = query.ExcludeItemTypes.ToList();
- var includeItemTypes = query.IncludeItemTypes.ToList();
-
- excludeItemTypes.Add(BaseItemKind.Year);
- excludeItemTypes.Add(BaseItemKind.Folder);
-
- if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre)))
- {
- if (!query.IncludeMedia)
- {
- AddIfMissing(includeItemTypes, BaseItemKind.Genre);
- AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
- }
- }
- else
- {
- AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
- AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
- }
-
- if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person)))
- {
- if (!query.IncludeMedia)
- {
- AddIfMissing(includeItemTypes, BaseItemKind.Person);
- }
- }
- else
- {
- AddIfMissing(excludeItemTypes, BaseItemKind.Person);
- }
-
- if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio)))
- {
- if (!query.IncludeMedia)
- {
- AddIfMissing(includeItemTypes, BaseItemKind.Studio);
- }
- }
- else
- {
- AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
- }
-
- if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist)))
- {
- if (!query.IncludeMedia)
- {
- AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
- }
- }
- else
- {
- AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
- }
-
- AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder);
- AddIfMissing(excludeItemTypes, BaseItemKind.Folder);
- var mediaTypes = query.MediaTypes.ToList();
-
- if (includeItemTypes.Count > 0)
- {
- excludeItemTypes.Clear();
- mediaTypes.Clear();
- }
-
- var searchQuery = new InternalItemsQuery(user)
- {
- SearchTerm = searchTerm,
- ExcludeItemTypes = excludeItemTypes.ToArray(),
- IncludeItemTypes = includeItemTypes.ToArray(),
- Limit = query.Limit,
- IncludeItemsByName = !query.ParentId.HasValue,
- ParentId = query.ParentId ?? Guid.Empty,
- OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
- Recursive = true,
-
- IsKids = query.IsKids,
- IsMovie = query.IsMovie,
- IsNews = query.IsNews,
- IsSeries = query.IsSeries,
- IsSports = query.IsSports,
- MediaTypes = mediaTypes.ToArray(),
-
- DtoOptions = new DtoOptions
- {
- Fields = new ItemFields[]
- {
- ItemFields.AirTime,
- ItemFields.DateCreated,
- ItemFields.ChannelInfo,
- ItemFields.ParentId
- }
- }
- };
-
- IReadOnlyList<BaseItem> mediaItems;
-
- if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
- {
- if (!searchQuery.ParentId.IsEmpty())
- {
- searchQuery.AncestorIds = [searchQuery.ParentId];
- searchQuery.ParentId = Guid.Empty;
- }
-
- searchQuery.IncludeItemsByName = true;
- searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>();
- mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList();
- }
- else
- {
- mediaItems = _libraryManager.GetItemList(searchQuery);
- }
-
- return mediaItems.Select(i => new SearchHintInfo
- {
- Item = i
- }).ToList();
- }
- }
-}
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..19b42a3a11 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -247,6 +247,103 @@ namespace Emby.Server.Implementations.Library
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);
+
+ 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]);
+ }
+ }
+
+ return result;
+ }
+
/// <summary>
/// Gets the internal key.
/// </summary>
@@ -281,6 +378,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 +486,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/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index d84afdc1b6..c0ad2c165a 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -50,7 +50,7 @@
"ScheduledTaskFailedWithName": "{0} αποτυχία",
"Shows": "Σειρές",
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
- "SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
+ "SubtitleDownloadFailureFromForItem": "Αποτυχία λήψης υποτίτλων από {0} για {1}",
"TvShows": "Τηλεοπτικές Σειρές",
"UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε",
"UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί",
@@ -106,5 +106,7 @@
"TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων",
"TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
- "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη"
+ "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
+ "LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
+ "Original": "Πρωτότυπο"
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index be152b515f..298d60d277 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
"CleanupUserDataTask": "User data cleanup task",
- "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days."
+ "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.",
+ "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
+ "Original": "Original"
}
diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json
index 28366a41b7..bccfdd4c19 100644
--- a/Emby.Server.Implementations/Localization/Core/es-AR.json
+++ b/Emby.Server.Implementations/Localization/Core/es-AR.json
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
"TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay",
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
- "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios"
+ "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios",
+ "LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
+ "Original": "Original"
}
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..44e057e4de 100644
--- a/Emby.Server.Implementations/Localization/Core/is.json
+++ b/Emby.Server.Implementations/Localization/Core/is.json
@@ -103,5 +103,8 @@
"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."
}
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/kn.json b/Emby.Server.Implementations/Localization/Core/kn.json
index f053619a7a..6009b50fe0 100644
--- a/Emby.Server.Implementations/Localization/Core/kn.json
+++ b/Emby.Server.Implementations/Localization/Core/kn.json
@@ -80,7 +80,7 @@
"NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ",
"NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ",
"NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
- "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
+ "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
"NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
"NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ",
"NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ",
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/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json
index 56806e25c1..92f309c80c 100644
--- a/Emby.Server.Implementations/Localization/Core/sr.json
+++ b/Emby.Server.Implementations/Localization/Core/sr.json
@@ -106,5 +106,7 @@
"CleanupUserDataTask": "Задатак чишћења корисничких података",
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
- "TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
+ "TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
+ "LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
+ "Original": "Изворно"
}
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 8b9665cf9a..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": "網路頻道",
@@ -106,5 +106,7 @@
"TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。",
"TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置",
"CleanupUserDataTask": "清理使用者資料嘅任務",
- "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。"
+ "CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
+ "LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
+ "Original": "原始"
}
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/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
index f81309560e..f1e1579a1d 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
@@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask
EnableImages = false
},
SourceTypes = [SourceType.Library],
- IsVirtualItem = false
+ IsVirtualItem = false,
+ IncludeOwnedItems = true
})
.OfType<Video>()
.ToList();
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
index 5e92808f78..9cc6eb265a 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
@@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask
DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library],
Recursive = true,
+ IncludeOwnedItems = true,
Limit = pagesize
};
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/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..52d8b4dad1 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -2049,7 +2049,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 5705284cfb..385795e9f7 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@@ -42,6 +43,7 @@ public class ItemsController : BaseJellyfinApiController
private readonly ILogger<ItemsController> _logger;
private readonly ISessionManager _sessionManager;
private readonly IUserDataManager _userDataRepository;
+ private readonly ISearchManager _searchManager;
/// <summary>
/// Initializes a new instance of the <see cref="ItemsController"/> class.
@@ -53,6 +55,7 @@ public class ItemsController : BaseJellyfinApiController
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
+ /// <param name="searchManager">Instance of the <see cref="ISearchManager"/> interface.</param>
public ItemsController(
IUserManager userManager,
ILibraryManager libraryManager,
@@ -60,7 +63,8 @@ public class ItemsController : BaseJellyfinApiController
IDtoService dtoService,
ILogger<ItemsController> logger,
ISessionManager sessionManager,
- IUserDataManager userDataRepository)
+ IUserDataManager userDataRepository,
+ ISearchManager searchManager)
{
_userManager = userManager;
_libraryManager = libraryManager;
@@ -69,6 +73,7 @@ public class ItemsController : BaseJellyfinApiController
_logger = logger;
_sessionManager = sessionManager;
_userDataRepository = userDataRepository;
+ _searchManager = searchManager;
}
/// <summary>
@@ -282,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
@@ -314,26 +321,22 @@ public class ItemsController : BaseJellyfinApiController
if (collectionType == CollectionType.playlists)
{
recursive = true;
- includeItemTypes = new[] { BaseItemKind.Playlist };
+ 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
@@ -346,218 +349,273 @@ 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)
+ // 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;
+
+ if (!string.IsNullOrWhiteSpace(searchTerm))
{
- var query = new InternalItemsQuery(user)
+ 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 = limit,
- StartIndex = startIndex,
- 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 = ids,
- MinCommunityRating = minCommunityRating,
- MinCriticRating = minCriticRating,
- ParentId = parentId ?? Guid.Empty,
- IndexNumber = indexNumber,
- ParentIndexNumber = parentIndexNumber,
- EnableTotalRecordCount = enableTotalRecordCount,
- ExcludeItemIds = excludeItemIds,
- DtoOptions = dtoOptions,
- SearchTerm = searchTerm,
- 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();
}
+ }
+
+ 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.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)
+ // Albums
+ if (albums.Length != 0)
+ {
+ query.AlbumIds = albums.SelectMany(i =>
{
- query.AlbumIds = albumIds;
- }
+ return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
+ }).ToArray();
+ }
- // Albums
- if (albums.Length != 0)
+ // Studios
+ if (studios.Length != 0)
+ {
+ query.StudioIds = studios.Select(i =>
{
- query.AlbumIds = albums.SelectMany(i =>
+ try
{
- return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 });
- }).ToArray();
- }
+ return _libraryManager.GetStudio(i);
+ }
+ catch
+ {
+ return null;
+ }
+ }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+ }
- // Studios
- if (studios.Length != 0)
+ // 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.StudioIds = studios.Select(i =>
- {
- try
- {
- return _libraryManager.GetStudio(i);
- }
- catch
- {
- return null;
- }
- }).Where(i => i is not null).Select(i => i!.Id).ToArray();
+ query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
}
+ }
+
+ query.Parent = null;
- // Apply default sorting if none requested
- if (query.OrderBy.Count == 0)
+ // 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)
{
- // Albums by artist
- if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
+ var orderedItems = result.Items
+ .OrderByDescending(item => searchResultScores.GetValueOrDefault(item.Id, 0f))
+ .ThenBy(item => item.SortName)
+ .ToArray();
+
+ var totalCount = orderedItems.Length;
+ if (startIndex.HasValue && startIndex.Value > 0)
{
- query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) };
+ orderedItems = orderedItems.Skip(startIndex.Value).ToArray();
}
- }
- query.Parent = null;
- result = folder.GetItems(query);
+ if (limit.HasValue)
+ {
+ orderedItems = orderedItems.Take(limit.Value).ToArray();
+ }
+
+ return new QueryResult<BaseItemDto>(
+ startIndex,
+ totalCount,
+ _dtoService.GetBaseItemDtos(orderedItems, dtoOptions, user));
+ }
}
else
{
@@ -905,15 +963,21 @@ 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();
}
var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
{
- OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
+ OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)],
IsResumable = true,
StartIndex = startIndex,
Limit = limit,
@@ -923,6 +987,7 @@ public class ItemsController : BaseJellyfinApiController
MediaTypes = mediaTypes,
IsVirtualItem = false,
CollapseBoxSetItems = false,
+ IncludeOwnedItems = true,
EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds,
IncludeItemTypes = includeItemTypes,
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/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs
index f22ac0b73a..ac7c091f85 100644
--- a/Jellyfin.Api/Controllers/MediaInfoController.cs
+++ b/Jellyfin.Api/Controllers/MediaInfoController.cs
@@ -213,7 +213,7 @@ public class MediaInfoController : BaseJellyfinApiController
Request.HttpContext.GetNormalizedRemoteIP());
}
- _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
+ _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
}
if (autoOpenLiveStream.Value)
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
index 0105ecf7a7..2c84fde972 100644
--- a/Jellyfin.Api/Controllers/PluginsController.cs
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -226,10 +226,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/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs
index ecf2335ba0..b03cb88e75 100644
--- a/Jellyfin.Api/Controllers/SearchController.cs
+++ b/Jellyfin.Api/Controllers/SearchController.cs
@@ -3,6 +3,7 @@ using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
+using System.Threading.Tasks;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Enums;
@@ -29,7 +30,7 @@ namespace Jellyfin.Api.Controllers;
[Authorize]
public class SearchController : BaseJellyfinApiController
{
- private readonly ISearchEngine _searchEngine;
+ private readonly ISearchManager _searchManager;
private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
private readonly IImageProcessor _imageProcessor;
@@ -37,17 +38,17 @@ public class SearchController : BaseJellyfinApiController
/// <summary>
/// Initializes a new instance of the <see cref="SearchController"/> class.
/// </summary>
- /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param>
+ /// <param name="searchManager">Instance of <see cref="ISearchManager"/> interface.</param>
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
/// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
public SearchController(
- ISearchEngine searchEngine,
+ ISearchManager searchManager,
ILibraryManager libraryManager,
IDtoService dtoService,
IImageProcessor imageProcessor)
{
- _searchEngine = searchEngine;
+ _searchManager = searchManager;
_libraryManager = libraryManager;
_dtoService = dtoService;
_imageProcessor = imageProcessor;
@@ -79,7 +80,7 @@ public class SearchController : BaseJellyfinApiController
[HttpGet]
[Description("Gets search hints based on a search term")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<SearchHintResult> GetSearchHints(
+ public async Task<ActionResult<SearchHintResult>> GetSearchHints(
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] Guid? userId,
@@ -100,7 +101,7 @@ public class SearchController : BaseJellyfinApiController
[FromQuery] bool includeArtists = true)
{
userId = RequestHelpers.GetUserId(User, userId);
- var result = _searchEngine.GetSearchHints(new SearchQuery
+ var result = await _searchManager.GetSearchHintsAsync(new SearchQuery
{
Limit = limit,
SearchTerm = searchTerm,
@@ -121,7 +122,7 @@ public class SearchController : BaseJellyfinApiController
IsNews = isNews,
IsSeries = isSeries,
IsSports = isSports
- });
+ }).ConfigureAwait(false);
return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount);
}
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/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs
index 2f5ed327c0..e53d15acfd 100644
--- a/Jellyfin.Api/Controllers/UniversalAudioController.cs
+++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs
@@ -163,7 +163,7 @@ public class UniversalAudioController : BaseJellyfinApiController
Request.HttpContext.GetNormalizedRemoteIP());
}
- _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
+ _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
foreach (var source in info.MediaSources)
{
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..09793553c2 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, BaseItemKind.MusicArtist];
+ 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/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
index 454d3f08e3..ef81235808 100644
--- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs
+++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
@@ -351,11 +351,20 @@ public class MediaInfoHelper
/// </summary>
/// <param name="result">Playback info response.</param>
/// <param name="maxBitrate">Max bitrate.</param>
- public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
+ /// <param name="preferredItemId">The id of the queried item, whose own media source must stay the default.</param>
+ public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default)
{
var originalList = result.MediaSources.ToList();
- result.MediaSources = result.MediaSources.OrderBy(i =>
+ // The queried item's source carries the user's resume state for that version, so it must stay the
+ // default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version.
+ var preferredId = preferredItemId.IsEmpty()
+ ? null
+ : preferredItemId.ToString("N", CultureInfo.InvariantCulture);
+
+ result.MediaSources = result.MediaSources
+ .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i =>
{
// Nothing beats direct playing a file
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
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..a534fa5fa0 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,
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index d6ddf8f5c8..decd45ae2c 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)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index f33a65a703..1198f99473 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));
}
}
@@ -586,8 +613,7 @@ public sealed partial class BaseItemRepository
if (filter.AlbumIds.Length > 0)
{
- var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id);
- baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album));
+ baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value));
}
if (filter.ExcludeArtistIds.Length > 0)
@@ -742,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)
@@ -953,24 +982,17 @@ public sealed partial class BaseItemRepository
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
{
- var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray();
- baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f)));
+ baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds);
}
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
{
- // Allow setting a null or empty value to get all items that have the specified provider set.
- var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray();
- if (includeAny.Length > 0)
- {
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId)));
- }
+ baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId);
+ }
- var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray();
- if (includeSelected.Length > 0)
- {
- baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f)));
- }
+ if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
+ {
+ baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
}
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
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 ffa5cff1f2..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();
@@ -557,9 +562,11 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
+ // Deduplicate; local (file-based) relationships take priority over linked (user-merged)
+ // ones, matching the LinkedChildren migration.
newLinkedChildren = newLinkedChildren
.GroupBy(c => c.ChildId)
- .Select(g => g.Last())
+ .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
.ToList();
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
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/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index 0791e04e85..58b9f7f822 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
+using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
@@ -28,7 +29,7 @@ namespace Jellyfin.Server.Implementations.Trickplay;
/// <summary>
/// ITrickplayManager implementation.
/// </summary>
-public class TrickplayManager : ITrickplayManager
+public partial class TrickplayManager : ITrickplayManager
{
private readonly ILogger<TrickplayManager> _logger;
private readonly IMediaEncoder _mediaEncoder;
@@ -135,6 +136,147 @@ public class TrickplayManager : ITrickplayManager
}
}
+ private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationToken)
+ {
+ var options = _config.Configuration.TrickplayOptions;
+ var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
+
+ // Remove DB rows whose on-disk folder no longer exists in either possible location.
+ // Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration.
+ var orphanedWidths = new List<int>();
+ foreach (var (width, info) in existing)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false);
+ var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true);
+ if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir))
+ {
+ orphanedWidths.Add(width);
+ }
+ }
+
+ if (orphanedWidths.Count > 0)
+ {
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ await dbContext.TrickplayInfos
+ .Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width))
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ foreach (var width in orphanedWidths)
+ {
+ _logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path);
+ existing.Remove(width);
+ }
+ }
+
+ var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
+ if (!Directory.Exists(trickplayDirectory))
+ {
+ return;
+ }
+
+ foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories())
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var match = TrickplaySubdirRegex().Match(subdir.Name);
+ if (!match.Success)
+ {
+ continue;
+ }
+
+ var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
+ var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
+ var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
+
+ if (existing.ContainsKey(width))
+ {
+ continue;
+ }
+
+ var tiles = subdir.GetFiles("*.jpg")
+ .OrderBy(t => t.Name, StringComparer.Ordinal)
+ .ToArray();
+ if (tiles.Length == 0)
+ {
+ continue;
+ }
+
+ // The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real
+ // thumbnail count cannot be read from tile dimensions. Instead, bound the count from
+ // the tile count and per-tile capacity, then pick an interval consistent with the
+ // video runtime - snapping to the server's configured interval when it fits.
+ var thumbsPerTile = tileWidth * tileHeight;
+ var maxThumbs = tiles.Length * thumbsPerTile;
+ var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1;
+
+ int interval;
+ int thumbnailCount;
+ if (video.RunTimeTicks is long ticks)
+ {
+ var runtimeMs = ticks / TimeSpan.TicksPerMillisecond;
+ var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs));
+ var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs));
+
+ if (options.Interval >= minInterval && options.Interval <= maxInterval)
+ {
+ interval = options.Interval;
+ }
+ else
+ {
+ var midpoint = (minInterval + maxInterval) / 2.0;
+ var snapped = (long)Math.Round(midpoint / 1000d) * 1000L;
+ interval = (int)Math.Clamp(snapped, minInterval, maxInterval);
+ }
+
+ thumbnailCount = Math.Clamp(
+ (int)Math.Round(runtimeMs / (double)interval),
+ minThumbs,
+ maxThumbs);
+ }
+ else
+ {
+ interval = Math.Max(1000, options.Interval);
+ thumbnailCount = maxThumbs;
+ }
+
+ var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName);
+ var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight));
+
+ var info = new TrickplayInfo
+ {
+ ItemId = video.Id,
+ Width = width,
+ Interval = interval,
+ TileWidth = tileWidth,
+ TileHeight = tileHeight,
+ ThumbnailCount = thumbnailCount,
+ Height = thumbPxH,
+ Bandwidth = 0,
+ };
+
+ foreach (var tile in tiles)
+ {
+ var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m));
+ info.Bandwidth = Math.Max(info.Bandwidth, bitrate);
+ }
+
+ await SaveTrickplayInfo(info).ConfigureAwait(false);
+ _logger.LogInformation(
+ "Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interval}ms interval) for {Path}",
+ width,
+ tileWidth,
+ tileHeight,
+ thumbnailCount,
+ interval,
+ video.Path);
+ }
+ }
+
/// <inheritdoc />
public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{
@@ -144,11 +286,27 @@ public class TrickplayManager : ITrickplayManager
return;
}
+ var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
+
+ // Catalog any existing trickplay folders on disk before any prune/generate. This picks up
+ // user-placed files even when their (width, tile dims) don't match the server's configured values.
+ if (!replace)
+ {
+ await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false);
+ }
+
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
+
+ // When extraction is disabled and files live next to media, treat them as user-managed:
+ // discovery above already catalogued whatever is on disk, leave it alone.
+ if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia)
+ {
+ return;
+ }
+
if (!libraryOptions.EnableTrickplayImageExtraction || replace)
{
// Prune existing data
@@ -688,6 +846,19 @@ public class TrickplayManager : ITrickplayManager
return Path.Combine(path, subdirectory);
}
+ [GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")]
+ private static partial Regex TrickplaySubdirRegex();
+
+ private static bool HasTrickplayTiles(string directory)
+ {
+ if (!Directory.Exists(directory))
+ {
+ return false;
+ }
+
+ return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any();
+ }
+
private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 37c4106496..bb877b62b0 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -51,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
private readonly IServerConfigurationManager _serverConfigurationManager;
- private readonly AsyncKeyedLocker<Guid> _userLock = new();
+ private readonly LockHelper _userLock = new();
/// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class.
@@ -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.");
}
@@ -214,7 +214,58 @@ namespace Jellyfin.Server.Implementations.Users
{
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{
- await UpdateUserInternalAsync(user).ConfigureAwait(false);
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ // TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead.
+ var dbUser = await UserQuery(dbContext)
+ .AsTracking()
+ .FirstOrDefaultAsync(u => u.Id == user.Id)
+ .ConfigureAwait(false)
+ ?? throw new ResourceNotFoundException(nameof(user.Id));
+
+ dbContext.Entry(dbUser).CurrentValues.SetValues(user);
+ dbUser.Permissions.Clear();
+ foreach (var permission in user.Permissions)
+ {
+ dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
+ }
+
+ dbUser.Preferences.Clear();
+ foreach (var preference in user.Preferences)
+ {
+ dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
+ }
+
+ dbUser.AccessSchedules.Clear();
+ foreach (var accessSchedule in user.AccessSchedules)
+ {
+ dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id));
+ }
+
+ if (user.ProfileImage is null)
+ {
+ if (dbUser.ProfileImage is not null)
+ {
+ dbContext.Remove(dbUser.ProfileImage);
+ dbUser.ProfileImage = null;
+ }
+ }
+ else if (dbUser.ProfileImage is null)
+ {
+ dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path)
+ {
+ LastModified = user.ProfileImage.LastModified
+ };
+ }
+ else
+ {
+ dbUser.ProfileImage.Path = user.ProfileImage.Path;
+ dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified;
+ }
+
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ }
}
}
@@ -453,12 +504,14 @@ namespace Jellyfin.Server.Implementations.Users
var user = GetUserByName(username);
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
{
+ using var dbContext = _dbProvider.CreateDbContext();
+
// Reload the user now that we hold the lock so the RowVersion is current.
// GetUserByName uses AsNoTracking and the snapshot may be stale if another
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
if (user is not null)
{
- user = GetUserById(user.Id) ?? user;
+ user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user;
}
var authResult = await AuthenticateLocalUser(username, password, user)
@@ -466,6 +519,13 @@ namespace Jellyfin.Server.Implementations.Users
var authenticationProvider = authResult.AuthenticationProvider;
success = authResult.Success;
+ if (success && user is not null)
+ {
+ // refresh the user if the auth provider might have updated it in the auth method.
+ // this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
+ user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
+ }
+
if (user is null)
{
string updatedUsername = authResult.Username;
@@ -479,11 +539,16 @@ namespace Jellyfin.Server.Implementations.Users
// Search the database for the user again
// the authentication provider might have created it
- user = GetUserByName(username);
+#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
+ user = await UserQuery(dbContext)
+ .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
{
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
+ user = await UserQuery(dbContext)
+ .FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
+#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
}
}
}
@@ -494,8 +559,10 @@ namespace Jellyfin.Server.Implementations.Users
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
{
- user.AuthenticationProviderId = providerId;
- await UpdateUserInternalAsync(user).ConfigureAwait(false);
+ await dbContext.Users
+ .Where(e => e.Id == user.Id)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
+ .ConfigureAwait(false);
}
}
@@ -542,16 +609,49 @@ namespace Jellyfin.Server.Implementations.Users
{
if (isUserSession)
{
- user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
+ var date = DateTime.UtcNow;
+ await dbContext.Users
+ .Where(e => e.Id == user.Id)
+ .ExecuteUpdateAsync(e => e
+ .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;
}
- user.InvalidLoginAttemptCount = 0;
- await UpdateUserInternalAsync(user).ConfigureAwait(false);
+ await dbContext.Users
+ .Where(e => e.Id == user.Id)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0))
+ .ConfigureAwait(false);
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
}
else
{
- await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
+ user.InvalidLoginAttemptCount++;
+ int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
+ 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);
+ _logger.LogWarning(
+ "Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
+ user.Username,
+ user.InvalidLoginAttemptCount);
+ }
+
+ await dbContext.Users
+ .Where(e => e.Id == user.Id)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1))
+ .ConfigureAwait(false);
+
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username,
@@ -926,32 +1026,6 @@ namespace Jellyfin.Server.Implementations.Users
}
}
- private async Task IncrementInvalidLoginAttemptCount(User user)
- {
- user.InvalidLoginAttemptCount++;
- int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
- if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
- {
- user.SetPermission(PermissionKind.IsDisabled, true);
- await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
- _logger.LogWarning(
- "Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
- user.Username,
- user.InvalidLoginAttemptCount);
- }
-
- await UpdateUserInternalAsync(user).ConfigureAwait(false);
- }
-
- private async Task UpdateUserInternalAsync(User user)
- {
- var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
- await using (dbContext.ConfigureAwait(false))
- {
- await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
- }
- }
-
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{
dbContext.Users.Attach(user);
@@ -977,5 +1051,70 @@ namespace Jellyfin.Server.Implementations.Users
_userLock.Dispose();
}
}
+
+ internal sealed class LockHelper : IDisposable
+ {
+ private readonly AsyncKeyedLocker<Guid> _userLock = new();
+
+ private bool _disposed;
+
+ public static AsyncLocal<int> IsNestedLock { get; set; } = new();
+
+ public bool ShouldLock()
+ {
+ return IsNestedLock.Value == 0;
+ }
+
+ public ValueTask<IDisposable> LockAsync(Guid key)
+ {
+ ThrowIfDisposed();
+ var isNested = LockHelper.IsNestedLock.Value != 0;
+ LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1;
+ if (isNested)
+ {
+ return new ValueTask<IDisposable>(new LockHandle { Parent = null });
+ }
+
+ return AcquireLockAsync(key);
+ }
+
+ private async ValueTask<IDisposable> AcquireLockAsync(Guid key)
+ {
+ var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false);
+ return new LockHandle { Parent = lockHandle };
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _userLock.Dispose();
+ }
+
+ private void ThrowIfDisposed()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ }
+
+ private sealed class LockHandle : IDisposable
+ {
+ public required IDisposable? Parent { get; init; }
+
+ public void Dispose()
+ {
+ Parent?.Dispose();
+ LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1;
+
+ if (LockHelper.IsNestedLock.Value < 0)
+ {
+ throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible.");
+ }
+ }
+ }
+ }
}
}
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/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
index 74f03f5107..c433c1d043 100644
--- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
@@ -223,6 +223,35 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
+ // Drop linked (user-merged) entries that point at items the parent owns (local
+ // file-based alternates or extras). These stem from legacy data that merged an
+ // owned item onto its own primary and would wrongly mark server-merged groups
+ // as user-merged (splittable).
+ var linkedChildIds = toInsert
+ .Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion)
+ .Select(lc => lc.ChildId)
+ .Distinct()
+ .ToList();
+
+ if (linkedChildIds.Count > 0)
+ {
+ var ownerIdByChildId = context.BaseItems
+ .WhereOneOrMany(linkedChildIds, b => b.Id)
+ .Where(b => b.OwnerId.HasValue)
+ .Select(b => new { b.Id, b.OwnerId })
+ .ToDictionary(b => b.Id, b => b.OwnerId!.Value);
+
+ var removedCount = toInsert.RemoveAll(lc =>
+ lc.ChildType == LinkedChildType.LinkedAlternateVersion
+ && ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId)
+ && ownerId.Equals(lc.ParentId));
+
+ if (removedCount > 0)
+ {
+ _logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned by their parent.", removedCount);
+ }
+ }
+
context.LinkedChildren.AddRange(toInsert);
context.SaveChanges();
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/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs
new file mode 100644
index 0000000000..d8dfe181ca
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260525010000_CleanupOrphanedExternalData.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that
+/// no longer exist in the <c>BaseItems</c> table. The database side is cleaned up synchronously by
+/// <c>IItemPersistenceService.DeleteItem</c>, so the leftover orphans live on the filesystem.
+/// </summary>
+[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class CleanupOrphanedExternalData : IAsyncMigrationRoutine
+{
+ private const int ProgressLogStep = 500;
+
+ private readonly IStartupLogger<CleanupOrphanedExternalData> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+ private readonly IApplicationPaths _appPaths;
+ private readonly IServerApplicationPaths _serverPaths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CleanupOrphanedExternalData"/> class.
+ /// </summary>
+ /// <param name="logger">The startup logger.</param>
+ /// <param name="dbContextFactory">The database context factory.</param>
+ /// <param name="appPaths">The application paths.</param>
+ /// <param name="serverPaths">The server application paths.</param>
+ public CleanupOrphanedExternalData(
+ IStartupLogger<CleanupOrphanedExternalData> logger,
+ IDbContextFactory<JellyfinDbContext> dbContextFactory,
+ IApplicationPaths appPaths,
+ IServerApplicationPaths serverPaths)
+ {
+ _logger = logger;
+ _dbContextFactory = dbContextFactory;
+ _appPaths = appPaths;
+ _serverPaths = serverPaths;
+ }
+
+ /// <inheritdoc/>
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false);
+
+ CleanupGuidIndexedRoot(
+ "attachment",
+ Path.Combine(_appPaths.DataPath, "attachments"),
+ knownIds,
+ deleteSubPath: null,
+ cancellationToken);
+
+ CleanupGuidIndexedRoot(
+ "subtitle",
+ Path.Combine(_appPaths.DataPath, "subtitles"),
+ knownIds,
+ deleteSubPath: null,
+ cancellationToken);
+
+ CleanupGuidIndexedRoot(
+ "trickplay",
+ _appPaths.TrickplayPath,
+ knownIds,
+ deleteSubPath: null,
+ cancellationToken);
+
+ CleanupGuidIndexedRoot(
+ "chapter image",
+ Path.Combine(_serverPaths.InternalMetadataPath, "library"),
+ knownIds,
+ deleteSubPath: "chapters",
+ cancellationToken);
+ }
+
+ private async Task<HashSet<Guid>> LoadKnownItemIdsAsync(CancellationToken cancellationToken)
+ {
+ var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ var ids = await context.BaseItems
+ .AsNoTracking()
+ .Select(b => b.Id)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+ return [.. ids];
+ }
+ }
+
+ private void CleanupGuidIndexedRoot(
+ string label,
+ string root,
+ HashSet<Guid> knownIds,
+ string? deleteSubPath,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
+ {
+ _logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root);
+ return;
+ }
+
+ _logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root);
+
+ var scanned = 0;
+ var removed = 0;
+ foreach (var prefixDir in Directory.EnumerateDirectories(root))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var prefixName = Path.GetFileName(prefixDir);
+ if (prefixName.Length != 2)
+ {
+ continue;
+ }
+
+ foreach (var guidDir in Directory.EnumerateDirectories(prefixDir))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ scanned++;
+ if (scanned % ProgressLogStep == 0)
+ {
+ _logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed);
+ }
+
+ var leafName = Path.GetFileName(guidDir);
+ if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id))
+ {
+ continue;
+ }
+
+ if (knownIds.Contains(id))
+ {
+ continue;
+ }
+
+ var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath);
+ if (deleteSubPath is not null && !Directory.Exists(target))
+ {
+ continue;
+ }
+
+ if (TryDelete(target))
+ {
+ removed++;
+ }
+ }
+ }
+
+ _logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed);
+ }
+
+ private bool TryDelete(string dir)
+ {
+ try
+ {
+ Directory.Delete(dir, recursive: true);
+ return true;
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ _logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir);
+ }
+
+ return false;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/20251008120000_RefreshCleanNames.cs b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs
index eca50ac100..7ade727d9b 100644
--- a/Jellyfin.Server/Migrations/Routines/20251008120000_RefreshCleanNames.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260610120000_RefreshCleanNamesAndValues.cs
@@ -12,22 +12,22 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
-/// Migration to refresh CleanName values for all library items.
+/// Migration to refresh CleanName values for all library items and CleanValue values for all item values.
/// </summary>
-[JellyfinMigration("2025-10-08T12:00:00", nameof(RefreshCleanNames))]
+[JellyfinMigration("2026-06-10T12:00:00", nameof(RefreshCleanNamesAndValues))]
[JellyfinMigrationBackup(JellyfinDb = true)]
-public class RefreshCleanNames : IAsyncMigrationRoutine
+public class RefreshCleanNamesAndValues : IAsyncMigrationRoutine
{
- private readonly IStartupLogger<RefreshCleanNames> _logger;
+ private readonly IStartupLogger<RefreshCleanNamesAndValues> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
/// <summary>
- /// Initializes a new instance of the <see cref="RefreshCleanNames"/> class.
+ /// Initializes a new instance of the <see cref="RefreshCleanNamesAndValues"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
- public RefreshCleanNames(
- IStartupLogger<RefreshCleanNames> logger,
+ public RefreshCleanNamesAndValues(
+ IStartupLogger<RefreshCleanNamesAndValues> logger,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
_logger = logger;
@@ -37,6 +37,12 @@ public class RefreshCleanNames : IAsyncMigrationRoutine
/// <inheritdoc />
public async Task PerformAsync(CancellationToken cancellationToken)
{
+ await RefreshCleanNamesAsync(cancellationToken).ConfigureAwait(false);
+ await RefreshCleanValuesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task RefreshCleanNamesAsync(CancellationToken cancellationToken)
+ {
const int Limit = 10000;
int itemCount = 0;
@@ -99,4 +105,69 @@ public class RefreshCleanNames : IAsyncMigrationRoutine
records,
sw.Elapsed);
}
+
+ private async Task RefreshCleanValuesAsync(CancellationToken cancellationToken)
+ {
+ const int Limit = 10000;
+ int itemCount = 0;
+
+ var sw = Stopwatch.StartNew();
+
+ using var context = _dbProvider.CreateDbContext();
+ var records = context.ItemValues.Count(b => !string.IsNullOrEmpty(b.Value));
+ _logger.LogInformation("Refreshing CleanValue for {Count} item values", records);
+
+ var processedInPartition = 0;
+
+ await foreach (var item in context.ItemValues
+ .Where(b => !string.IsNullOrEmpty(b.Value))
+ .OrderBy(e => e.ItemValueId)
+ .WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed))
+ .PartitionEagerAsync(Limit, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ try
+ {
+ var newCleanValue = string.IsNullOrWhiteSpace(item.Value) ? string.Empty : item.Value.GetCleanValue();
+ if (!string.Equals(newCleanValue, item.CleanValue, StringComparison.Ordinal))
+ {
+ _logger.LogDebug(
+ "Updating CleanValue for item value {Id}: '{OldValue}' -> '{NewValue}'",
+ item.ItemValueId,
+ item.CleanValue,
+ newCleanValue);
+ item.CleanValue = newCleanValue;
+ itemCount++;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to update CleanValue for item value {Id} ({Value})", item.ItemValueId, item.Value);
+ }
+
+ processedInPartition++;
+
+ if (processedInPartition >= Limit)
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ // Clear tracked entities to avoid memory growth across partitions
+ context.ChangeTracker.Clear();
+ processedInPartition = 0;
+ }
+ }
+
+ // Save any remaining changes after the loop
+ if (processedInPartition > 0)
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ context.ChangeTracker.Clear();
+ }
+
+ _logger.LogInformation(
+ "Refreshed CleanValue for {UpdatedCount} out of {TotalCount} item values in {Time}",
+ itemCount,
+ records,
+ sw.Elapsed);
+ }
}
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/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/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/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/IO/IExternalDataManager.cs b/MediaBrowser.Controller/IO/IExternalDataManager.cs
index f69f4586c6..b2eb8fc3f1 100644
--- a/MediaBrowser.Controller/IO/IExternalDataManager.cs
+++ b/MediaBrowser.Controller/IO/IExternalDataManager.cs
@@ -16,4 +16,11 @@ public interface IExternalDataManager
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Deletes only the filesystem-side external item data (attachments, subtitles, trickplay, chapter images).
+ /// Use this when DB-side cleanup is already handled by another code path (e.g. <c>IItemPersistenceService.DeleteItem</c>).
+ /// </summary>
+ /// <param name="item">The item.</param>
+ void DeleteExternalItemFiles(BaseItem item);
}
diff --git a/MediaBrowser.Controller/Library/IExternalSearchProvider.cs b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs
new file mode 100644
index 0000000000..bded8ba3a3
--- /dev/null
+++ b/MediaBrowser.Controller/Library/IExternalSearchProvider.cs
@@ -0,0 +1,20 @@
+using System.Collections.Generic;
+using System.Threading;
+
+namespace MediaBrowser.Controller.Library;
+
+/// <summary>
+/// Interface for external search providers that offer enhanced search capabilities.
+/// </summary>
+public interface IExternalSearchProvider : ISearchProvider
+{
+ /// <summary>
+ /// Searches for items matching the query.
+ /// </summary>
+ /// <param name="query">The search query.</param>
+ /// <param name="cancellationToken">Cancellation token.</param>
+ /// <returns>Async enumerable of search results with relevance scores.</returns>
+ new IAsyncEnumerable<SearchResult> SearchAsync(
+ SearchProviderQuery query,
+ CancellationToken cancellationToken);
+}
diff --git a/MediaBrowser.Controller/Library/IInternalSearchProvider.cs b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs
new file mode 100644
index 0000000000..f87931395d
--- /dev/null
+++ b/MediaBrowser.Controller/Library/IInternalSearchProvider.cs
@@ -0,0 +1,8 @@
+namespace MediaBrowser.Controller.Library;
+
+/// <summary>
+/// Marker interface for internal search providers that typically query the local database directly.
+/// </summary>
+public interface IInternalSearchProvider : ISearchProvider
+{
+}
diff --git a/MediaBrowser.Controller/Library/ISearchEngine.cs b/MediaBrowser.Controller/Library/ISearchEngine.cs
deleted file mode 100644
index 31dcbba5bd..0000000000
--- a/MediaBrowser.Controller/Library/ISearchEngine.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Search;
-
-namespace MediaBrowser.Controller.Library
-{
- /// <summary>
- /// Interface ILibrarySearchEngine.
- /// </summary>
- public interface ISearchEngine
- {
- /// <summary>
- /// Gets the search hints.
- /// </summary>
- /// <param name="query">The query.</param>
- /// <returns>Task{IEnumerable{SearchHintInfo}}.</returns>
- QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query);
- }
-}
diff --git a/MediaBrowser.Controller/Library/ISearchManager.cs b/MediaBrowser.Controller/Library/ISearchManager.cs
new file mode 100644
index 0000000000..4f763829a7
--- /dev/null
+++ b/MediaBrowser.Controller/Library/ISearchManager.cs
@@ -0,0 +1,48 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Querying;
+using MediaBrowser.Model.Search;
+
+namespace MediaBrowser.Controller.Library;
+
+/// <summary>
+/// Orchestrates search operations across registered search providers.
+/// </summary>
+public interface ISearchManager
+{
+ /// <summary>
+ /// Searches for items and returns hints suitable for autocomplete/typeahead UI.
+ /// Results are ordered by relevance score from search providers.
+ /// </summary>
+ /// <param name="query">The search query including filters and pagination.</param>
+ /// <param name="cancellationToken">Cancellation token.</param>
+ /// <returns>Paginated search hints with item metadata for display.</returns>
+ Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(
+ SearchQuery query,
+ CancellationToken cancellationToken = default);
+
+ /// <summary>
+ /// Gets ranked search results from registered providers. Returns only item IDs and
+ /// relevance scores; callers are responsible for loading items and applying user-access filtering.
+ /// </summary>
+ /// <param name="query">The search provider query with type/media filters.</param>
+ /// <param name="cancellationToken">Cancellation token.</param>
+ /// <returns>Search results containing item IDs and relevance scores.</returns>
+ Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
+ SearchProviderQuery query,
+ CancellationToken cancellationToken = default);
+
+ /// <summary>
+ /// Registers search providers discovered through dependency injection.
+ /// Called during application startup.
+ /// </summary>
+ /// <param name="providers">The search providers to register.</param>
+ void AddParts(IEnumerable<ISearchProvider> providers);
+
+ /// <summary>
+ /// Gets all registered search providers ordered by priority.
+ /// </summary>
+ /// <returns>The list of search providers including the SQL fallback provider.</returns>
+ IReadOnlyList<ISearchProvider> GetProviders();
+}
diff --git a/MediaBrowser.Controller/Library/ISearchProvider.cs b/MediaBrowser.Controller/Library/ISearchProvider.cs
new file mode 100644
index 0000000000..3b300ed38b
--- /dev/null
+++ b/MediaBrowser.Controller/Library/ISearchProvider.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Configuration;
+
+namespace MediaBrowser.Controller.Library;
+
+/// <summary>
+/// Interface for search providers.
+/// </summary>
+public interface ISearchProvider
+{
+ /// <summary>
+ /// Gets the name of the provider.
+ /// </summary>
+ string Name { get; }
+
+ /// <summary>
+ /// Gets the type of the provider.
+ /// </summary>
+ MetadataPluginType Type { get; }
+
+ /// <summary>
+ /// Gets the priority of the provider. Lower values execute first.
+ /// </summary>
+ int Priority { get; }
+
+ /// <summary>
+ /// Searches for items matching the query.
+ /// </summary>
+ /// <param name="query">The search query.</param>
+ /// <param name="cancellationToken">Cancellation token.</param>
+ /// <returns>Ranked list of candidate item IDs with scores.</returns>
+ Task<IReadOnlyList<SearchResult>> SearchAsync(
+ SearchProviderQuery query,
+ CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Determines whether this provider can handle the given query.
+ /// </summary>
+ /// <param name="query">The search query to evaluate.</param>
+ /// <returns>True if this provider can search for the query; otherwise, false.</returns>
+ bool CanSearch(SearchProviderQuery query);
+}
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/SearchProviderQuery.cs b/MediaBrowser.Controller/Library/SearchProviderQuery.cs
new file mode 100644
index 0000000000..845588c872
--- /dev/null
+++ b/MediaBrowser.Controller/Library/SearchProviderQuery.cs
@@ -0,0 +1,45 @@
+using System;
+using Jellyfin.Data.Enums;
+
+namespace MediaBrowser.Controller.Library;
+
+/// <summary>
+/// Query object for search providers.
+/// </summary>
+public class SearchProviderQuery
+{
+ /// <summary>
+ /// Gets the search term.
+ /// </summary>
+ public required string SearchTerm { get; init; }
+
+ /// <summary>
+ /// Gets the user ID for user-specific searches.
+ /// </summary>
+ public Guid? UserId { get; init; }
+
+ /// <summary>
+ /// Gets the item types to include in the search.
+ /// </summary>
+ public BaseItemKind[] IncludeItemTypes { get; init; } = [];
+
+ /// <summary>
+ /// Gets the item types to exclude from the search.
+ /// </summary>
+ public BaseItemKind[] ExcludeItemTypes { get; init; } = [];
+
+ /// <summary>
+ /// Gets the media types to include in the search.
+ /// </summary>
+ public MediaType[] MediaTypes { get; init; } = [];
+
+ /// <summary>
+ /// Gets the maximum number of results to return.
+ /// </summary>
+ public int? Limit { get; init; }
+
+ /// <summary>
+ /// Gets the parent ID to scope the search.
+ /// </summary>
+ public Guid? ParentId { get; init; }
+}
diff --git a/MediaBrowser.Controller/Library/SearchResult.cs b/MediaBrowser.Controller/Library/SearchResult.cs
new file mode 100644
index 0000000000..e6f145e979
--- /dev/null
+++ b/MediaBrowser.Controller/Library/SearchResult.cs
@@ -0,0 +1,60 @@
+using System;
+
+namespace MediaBrowser.Controller.Library;
+
+/// <summary>
+/// Represents an item matched by a search query with its relevance score.
+/// </summary>
+public readonly struct SearchResult : IEquatable<SearchResult>
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SearchResult"/> struct.
+ /// </summary>
+ /// <param name="itemId">The item ID.</param>
+ /// <param name="score">The relevance score.</param>
+ public SearchResult(Guid itemId, float score)
+ {
+ ItemId = itemId;
+ Score = score;
+ }
+
+ /// <summary>
+ /// Gets the ID of the matching item.
+ /// </summary>
+ public Guid ItemId { get; init; }
+
+ /// <summary>
+ /// Gets the relevance score. Higher values indicate more relevant results.
+ /// </summary>
+ public float Score { get; init; }
+
+ /// <summary>
+ /// Compares two <see cref="SearchResult"/> instances for equality.
+ /// </summary>
+ /// <param name="left">The left operand.</param>
+ /// <param name="right">The right operand.</param>
+ /// <returns>True if the instances are equal; otherwise, false.</returns>
+ public static bool operator ==(SearchResult left, SearchResult right)
+ => left.Equals(right);
+
+ /// <summary>
+ /// Compares two <see cref="SearchResult"/> instances for inequality.
+ /// </summary>
+ /// <param name="left">The left operand.</param>
+ /// <param name="right">The right operand.</param>
+ /// <returns>True if the instances are not equal; otherwise, false.</returns>
+ public static bool operator !=(SearchResult left, SearchResult right)
+ => !left.Equals(right);
+
+ /// <inheritdoc/>
+ public override bool Equals(object? obj)
+ => obj is SearchResult other && Equals(other);
+
+ /// <inheritdoc/>
+ public bool Equals(SearchResult other)
+ => ItemId.Equals(other.ItemId) && Score.Equals(other.Score);
+
+ /// <inheritdoc/>
+ public override int GetHashCode()
+ => HashCode.Combine(ItemId, Score);
+}
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 ff8d84d45e..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;
@@ -444,6 +445,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
}
+ private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
+ {
+ var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
+ var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
+ return doDeintH264 || doDeintHevc;
+ }
+
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
{
var videoStream = state.VideoStream;
@@ -2604,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
@@ -2661,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)
@@ -3850,9 +3868,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase);
var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doToneMap = IsSwTonemapAvailable(state, options);
var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI;
@@ -4004,9 +4020,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isCuInCuOut = isNvDecoder && isNvencEncoder;
var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doCuTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4040,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);
@@ -4215,9 +4229,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4463,9 +4475,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVppTonemap || doOclTonemap;
@@ -4757,12 +4767,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap;
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5088,12 +5096,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isVaInVaOut = isVaapiDecoder && isVaapiEncoder;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap;
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5325,10 +5331,8 @@ namespace MediaBrowser.Controller.MediaEncoding
var isSwEncoder = !isVaapiEncoder;
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVkTonemap = IsVulkanHwTonemapAvailable(state, options);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5565,9 +5569,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965;
var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -5798,9 +5800,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var reqMaxH = state.BaseRequest.MaxHeight;
var threeDFormat = state.MediaSource.Video3DFormat;
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options);
var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options);
var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface);
@@ -5999,9 +5999,7 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase)
|| vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase));
- var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
- var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -6265,12 +6263,21 @@ namespace MediaBrowser.Controller.MediaEncoding
overlayFilters?.RemoveAll(string.IsNullOrEmpty);
var framerate = GetFramerateParam(state);
- if (framerate.HasValue)
+ if (mainFilters is not null && framerate.HasValue)
{
- mainFilters.Insert(0, string.Format(
- CultureInfo.InvariantCulture,
- "fps={0}",
- framerate.Value));
+ var doDeintH2645 = IsDeinterlaceAvailable(state);
+ var fpsFilter = string.Format(CultureInfo.InvariantCulture, "fps={0}", framerate.Value);
+
+ // For filter chain containing the deinterlace filter,
+ // place the fps filter at the end to preserve temporal info.
+ if (doDeintH2645)
+ {
+ mainFilters.Add(fpsFilter);
+ }
+ else
+ {
+ mainFilters.Insert(0, fpsFilter);
+ }
}
var mainStr = string.Empty;
@@ -7221,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";
@@ -7236,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);
+ }
}
}
@@ -7855,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,
@@ -7869,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/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..c8670c67cc 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;
@@ -645,7 +646,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/FFProbeHelpers.cs b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs
index 975c2b8161..fa2085ca6f 100644
--- a/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs
+++ b/MediaBrowser.MediaEncoding/Probing/FFProbeHelpers.cs
@@ -76,7 +76,13 @@ namespace MediaBrowser.MediaEncoding.Probing
/// <returns>Dictionary{System.StringSystem.String}.</returns>
private static Dictionary<string, string?> ConvertDictionaryToCaseInsensitive(IReadOnlyDictionary<string, string?> dict)
{
- return new Dictionary<string, string?>(dict, StringComparer.OrdinalIgnoreCase);
+ var result = new Dictionary<string, string?>(dict.Count, StringComparer.OrdinalIgnoreCase);
+ foreach (var (key, value) in dict)
+ {
+ result.TryAdd(key, value);
+ }
+
+ return result;
}
}
}
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/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 77aadee704..28f7b92268 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -167,7 +167,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
if (fileInfo.Protocol == MediaProtocol.Http)
{
- 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)
@@ -214,7 +214,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
};
}
- var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec).TrimStart('.');
+ // Normalize ffmpeg codec names to the file extensions the parser is keyed on
+ var currentFormat = NormalizeCodecToParserExtension((Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec).TrimStart('.'));
// Handle PGS subtitles as raw streams for the client to render
if (MediaStream.IsPgsFormat(currentFormat))
@@ -324,13 +325,91 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{
- if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
+ if (!IsCachedSubtitleFresh(outputPath, subtitleStream.Path))
{
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
}
}
}
+ // ffmpeg codec names don't always match the file extensions the subtitle parser is keyed on.
+ private static string NormalizeCodecToParserExtension(string codecOrExtension)
+ {
+ return codecOrExtension switch
+ {
+ "subrip" => "srt",
+ "webvtt" => "vtt",
+ _ => codecOrExtension
+ };
+ }
+
+ // Records "this cache was built from this exact source revision" in a sidecar file next to the cache: "<sizeBytes>:<mtimeTicks>"
+ private static string GetCacheMetaPath(string cachePath) => cachePath + ".meta";
+
+ private static string FormatCacheMeta(long length, DateTime lastWriteUtc)
+ => string.Create(CultureInfo.InvariantCulture, $"{length}:{lastWriteUtc.Ticks}");
+
+ private bool IsCachedSubtitleFresh(string cachePath, string? sourcePath)
+ {
+ if (!File.Exists(cachePath))
+ {
+ return false;
+ }
+
+ var cacheInfo = _fileSystem.GetFileInfo(cachePath);
+ if (cacheInfo.Length == 0)
+ {
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath))
+ {
+ return true;
+ }
+
+ var metaPath = GetCacheMetaPath(cachePath);
+ if (!File.Exists(metaPath))
+ {
+ // Pre-existing cache from before metadata tracking - regenerate so we can record the source state.
+ return false;
+ }
+
+ try
+ {
+ var sourceInfo = _fileSystem.GetFileInfo(sourcePath);
+ var expected = FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc);
+ var actual = File.ReadAllText(metaPath);
+ return string.Equals(expected, actual, StringComparison.Ordinal);
+ }
+ catch (IOException)
+ {
+ return false;
+ }
+ }
+
+ private void WriteCacheMeta(string cachePath, string? sourcePath)
+ {
+ if (string.IsNullOrEmpty(sourcePath))
+ {
+ return;
+ }
+
+ try
+ {
+ var sourceInfo = _fileSystem.GetFileInfo(sourcePath);
+ if (!sourceInfo.Exists)
+ {
+ return;
+ }
+
+ File.WriteAllText(GetCacheMetaPath(cachePath), FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc));
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Failed to record subtitle cache metadata for {CachePath}", cachePath);
+ }
+ }
+
/// <summary>
/// Converts the text subtitle to SRT internal.
/// </summary>
@@ -366,96 +445,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, "{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;
-
- 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;
+ var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
- 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);
- _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
+ WriteCacheMeta(outputPath, inputPath);
}
private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
@@ -531,7 +529,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
- if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
+ var sourcePath = string.IsNullOrEmpty(subtitleStream.Path) ? mediaSource.Path : subtitleStream.Path;
+ if (IsCachedSubtitleFresh(outputPath, sourcePath))
{
releaser.Dispose();
continue;
@@ -588,7 +587,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)
@@ -628,6 +627,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
+
+ foreach (var outputPath in outputPaths)
+ {
+ WriteCacheMeta(outputPath, mksFile);
+ }
}
}
@@ -640,7 +644,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)
@@ -683,56 +687,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (outputPaths.Count > 0)
{
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
+
+ foreach (var outputPath in outputPaths)
+ {
+ WriteCacheMeta(outputPath, mediaSource.Path);
+ }
}
}
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;
@@ -792,6 +761,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));
}
@@ -849,16 +825,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
{
@@ -866,8 +864,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
},
@@ -883,14 +883,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)
@@ -898,59 +905,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);
+ standardError = await standardErrorTask.ConfigureAwait(false);
}
- catch (FileNotFoundException)
- {
- }
- 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>
@@ -1012,7 +978,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
@@ -1028,8 +994,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:
@@ -1049,7 +1016,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/MetadataPluginType.cs b/MediaBrowser.Model/Configuration/MetadataPluginType.cs
index 476060ceef..dd9a599a29 100644
--- a/MediaBrowser.Model/Configuration/MetadataPluginType.cs
+++ b/MediaBrowser.Model/Configuration/MetadataPluginType.cs
@@ -17,6 +17,7 @@ namespace MediaBrowser.Model.Configuration
LyricFetcher,
MediaSegmentProvider,
LocalSimilarityProvider,
- SimilarityProvider
+ SimilarityProvider,
+ SearchProvider
}
}
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/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/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
new file mode 100644
index 0000000000..787d2ad878
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
@@ -0,0 +1,238 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.Books.ComicBookInfo.Models;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo;
+
+/// <summary>
+/// ComicBookInfo provider.
+/// </summary>
+public class ComicBookInfoProvider : IComicProvider
+{
+ private readonly ILogger<ComicBookInfoProvider> _logger;
+ private readonly IFileSystem _fileSystem;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ComicBookInfoProvider"/> class.
+ /// </summary>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{ComicBookInfoProvider}"/> interface.</param>
+ public ComicBookInfoProvider(IFileSystem fileSystem, ILogger<ComicBookInfoProvider> logger)
+ {
+ _fileSystem = fileSystem;
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public async ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var path = GetComicBookFile(info.Path)?.FullName;
+
+ if (path is null)
+ {
+ _logger.LogError("could not load comic: {Path}", info.Path);
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ try
+ {
+ Stream stream = AsyncFile.OpenRead(path);
+ await using (stream.ConfigureAwait(false))
+ {
+ var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, false, null, cancellationToken).ConfigureAwait(false);
+ await using (archive.ConfigureAwait(false))
+ {
+ if (archive.Comment is null)
+ {
+ _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path);
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var comicBookMetadata = JsonSerializer.Deserialize<ComicBookInfoFormat>(archive.Comment, JsonDefaults.Options);
+ if (comicBookMetadata is null)
+ {
+ _logger.LogError("ComicBookInfo deserialization failure: {Path}", info.Path);
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ return SaveMetadata(comicBookMetadata);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path);
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+ }
+
+ /// <inheritdoc />
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetComicBookFile(item.Path);
+
+ if (file is null)
+ {
+ return false;
+ }
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private MetadataResult<Book> SaveMetadata(ComicBookInfoFormat comic)
+ {
+ if (comic.Metadata is null)
+ {
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var book = ReadComicBookMetadata(comic.Metadata);
+
+ if (book is null)
+ {
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
+
+ if (comic.Metadata.Language is not null)
+ {
+ metadataResult.ResultLanguage = ReadCultureInfoInto(comic.Metadata.Language);
+ }
+
+ if (comic.Metadata.Credits.Count > 0)
+ {
+ ReadPeopleMetadata(comic.Metadata, metadataResult);
+ }
+
+ return metadataResult;
+ }
+
+ private FileSystemMetadata? GetComicBookFile(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+
+ if (fileInfo.IsDirectory)
+ {
+ return null;
+ }
+
+ // only parse files that are known to have ComicBookInfo metadata
+ return fileInfo.Extension.Equals(".cbz", StringComparison.OrdinalIgnoreCase) ? fileInfo : null;
+ }
+
+ private static Book? ReadComicBookMetadata(ComicBookInfoMetadata comic)
+ {
+ var book = new Book();
+ var hasFoundMetadata = false;
+
+ hasFoundMetadata |= ReadStringInto(comic.Title, title => book.Name = title);
+ hasFoundMetadata |= ReadStringInto(comic.Series, series => book.SeriesName = series);
+ hasFoundMetadata |= ReadStringInto(comic.Genre, genre => book.AddGenre(genre));
+ hasFoundMetadata |= ReadStringInto(comic.Comments, overview => book.Overview = overview);
+ hasFoundMetadata |= ReadStringInto(comic.Publisher, publisher => book.SetStudios([publisher]));
+
+ if (comic.PublicationYear is not null)
+ {
+ book.ProductionYear = comic.PublicationYear;
+ hasFoundMetadata = true;
+ }
+
+ if (comic.Issue is not null)
+ {
+ book.IndexNumber = comic.Issue;
+ hasFoundMetadata = true;
+ }
+
+ if (comic.Tags.Count > 0)
+ {
+ book.Tags = comic.Tags.ToArray();
+ hasFoundMetadata = true;
+ }
+
+ if (comic.PublicationYear is not null && comic.PublicationMonth is not null)
+ {
+ book.PremiereDate = ReadTwoPartDateInto(comic.PublicationYear.Value, comic.PublicationMonth.Value);
+ hasFoundMetadata = true;
+ }
+
+ return hasFoundMetadata ? book : null;
+ }
+
+ private static void ReadPeopleMetadata(ComicBookInfoMetadata comic, MetadataResult<Book> metadataResult)
+ {
+ foreach (var person in comic.Credits)
+ {
+ if (person.Person is null || person.Role is null)
+ {
+ continue;
+ }
+
+ if (person.Person.Contains(',', StringComparison.InvariantCultureIgnoreCase))
+ {
+ var name = person.Person.Split(',');
+ person.Person = name[1].Trim(' ') + " " + name[0].Trim(' ');
+ }
+
+ if (!Enum.TryParse(person.Role, out PersonKind personKind))
+ {
+ personKind = PersonKind.Unknown;
+ }
+
+ if (string.Equals("Colorer", person.Role, StringComparison.OrdinalIgnoreCase))
+ {
+ personKind = PersonKind.Colorist;
+ }
+
+ metadataResult.AddPerson(new PersonInfo { Name = person.Person, Type = personKind });
+ }
+ }
+
+ private static string? ReadCultureInfoInto(string language)
+ {
+ try
+ {
+ return CultureInfo.GetCultureInfo(language).DisplayName;
+ }
+ catch (CultureNotFoundException)
+ {
+ return null;
+ }
+ }
+
+ private static bool ReadStringInto(string? data, Action<string> commitResult)
+ {
+ if (!string.IsNullOrWhiteSpace(data))
+ {
+ commitResult(data);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static DateTime? ReadTwoPartDateInto(int year, int month)
+ {
+ try
+ {
+ // use first day of the month because this format doesn't include a day
+ return new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs
new file mode 100644
index 0000000000..fe7aa40456
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoCredit.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
+
+/// <summary>
+/// ComicBookInfo credit.
+/// </summary>
+public class ComicBookInfoCredit
+{
+ /// <summary>
+ /// Gets or sets the person name.
+ /// </summary>
+ [JsonPropertyName("person")]
+ public string? Person { get; set; }
+
+ /// <summary>
+ /// Gets or sets the role.
+ /// </summary>
+ [JsonPropertyName("role")]
+ public string? Role { get; set; }
+}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs
new file mode 100644
index 0000000000..5c4e3d948f
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoFormat.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
+
+/// <summary>
+/// ComicBookInfo format.
+/// </summary>
+public class ComicBookInfoFormat
+{
+ /// <summary>
+ /// Gets or sets the app ID.
+ /// </summary>
+ [JsonPropertyName("appID")]
+ public string? AppId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the last modified timestamp.
+ /// </summary>
+ [JsonPropertyName("lastModified")]
+ public string? LastModified { get; set; }
+
+ /// <summary>
+ /// Gets or sets the metadata.
+ /// </summary>
+ [JsonPropertyName("ComicBookInfo/1.0")]
+ public ComicBookInfoMetadata? Metadata { get; set; }
+}
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs
new file mode 100644
index 0000000000..42e1b3d4f6
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/Models/ComicBookInfoMetadata.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
+
+/// <summary>
+/// ComicBookInfo metadata.
+/// </summary>
+public class ComicBookInfoMetadata
+{
+ /// <summary>
+ /// Gets or sets the series.
+ /// </summary>
+ [JsonPropertyName("series")]
+ public string? Series { get; set; }
+
+ /// <summary>
+ /// Gets or sets the title.
+ /// </summary>
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ /// <summary>
+ /// Gets or sets the publisher.
+ /// </summary>
+ [JsonPropertyName("publisher")]
+ public string? Publisher { get; set; }
+
+ /// <summary>
+ /// Gets or sets the publication month.
+ /// </summary>
+ [JsonPropertyName("publicationMonth")]
+ public int? PublicationMonth { get; set; }
+
+ /// <summary>
+ /// Gets or sets the publication year.
+ /// </summary>
+ [JsonPropertyName("publicationYear")]
+ public int? PublicationYear { get; set; }
+
+ /// <summary>
+ /// Gets or sets the issue number.
+ /// </summary>
+ [JsonPropertyName("issue")]
+ public int? Issue { get; set; }
+
+ /// <summary>
+ /// Gets or sets the number of issues.
+ /// </summary>
+ [JsonPropertyName("numberOfIssues")]
+ public int? NumberOfIssues { get; set; }
+
+ /// <summary>
+ /// Gets or sets the volume number.
+ /// </summary>
+ [JsonPropertyName("volume")]
+ public int? Volume { get; set; }
+
+ /// <summary>
+ /// Gets or sets the number of volumes.
+ /// </summary>
+ [JsonPropertyName("numberOfVolumes")]
+ public int? NumberOfVolumes { get; set; }
+
+ /// <summary>
+ /// Gets or sets the rating.
+ /// </summary>
+ [JsonPropertyName("rating")]
+ public int? Rating { get; set; }
+
+ /// <summary>
+ /// Gets or sets the genre.
+ /// </summary>
+ [JsonPropertyName("genre")]
+ public string? Genre { get; set; }
+
+ /// <summary>
+ /// Gets or sets the language.
+ /// </summary>
+ [JsonPropertyName("language")]
+ public string? Language { get; set; }
+
+ /// <summary>
+ /// Gets or sets the country.
+ /// </summary>
+ [JsonPropertyName("country")]
+ public string? Country { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of credits.
+ /// </summary>
+ [JsonPropertyName("credits")]
+ public IReadOnlyList<ComicBookInfoCredit> Credits { get; set; } = Array.Empty<ComicBookInfoCredit>();
+
+ /// <summary>
+ /// Gets or sets the list of tags.
+ /// </summary>
+ [JsonPropertyName("tags")]
+ public IReadOnlyList<string> Tags { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the comments.
+ /// </summary>
+ [JsonPropertyName("comments")]
+ public string? Comments { get; set; }
+}
diff --git a/MediaBrowser.Providers/Books/ComicImageProvider.cs b/MediaBrowser.Providers/Books/ComicImageProvider.cs
new file mode 100644
index 0000000000..34936cff13
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicImageProvider.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Extensions;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+using SharpCompress.Archives;
+
+namespace MediaBrowser.Providers.Books;
+
+/// <summary>
+/// The ComicImageProvider tries to find either an image named "cover" or, in case that
+/// fails, just takes the first image inside the archive, hoping that it is the cover.
+/// </summary>
+public class ComicImageProvider : IDynamicImageProvider
+{
+ private readonly string[] _comicBookExtensions = [".cb7", ".cbr", ".cbt", ".cbz"];
+ private readonly string[] _coverExtensions = [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"];
+
+ private readonly ILogger<ComicImageProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ComicImageProvider"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{ComicImageProvider}"/> interface.</param>
+ public ComicImageProvider(ILogger<ComicImageProvider> logger)
+ {
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public string Name => "Comic Book Archive Cover Extractor";
+
+ /// <inheritdoc />
+ public async Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
+ {
+ var extension = Path.GetExtension(item.Path);
+
+ if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
+ {
+ return await LoadCoverAsync(item, cancellationToken).ConfigureAwait(false);
+ }
+
+ return new DynamicImageResponse { HasImage = false };
+ }
+
+ /// <inheritdoc />
+ public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
+ {
+ yield return ImageType.Primary;
+ }
+
+ /// <inheritdoc />
+ public bool Supports(BaseItem item)
+ {
+ return item is Book;
+ }
+
+ /// <summary>
+ /// Tries to load a cover from the CBZ archive. Returns a response
+ /// with no image if nothing is found.
+ /// </summary>
+ /// <param name="item">Item to check for covers.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ private async Task<DynamicImageResponse> LoadCoverAsync(BaseItem item, CancellationToken cancellationToken)
+ {
+ var memoryStream = new MemoryStream();
+
+ try
+ {
+ ImageFormat imageFormat;
+
+ using (Stream stream = AsyncFile.OpenRead(item.Path))
+ {
+ var archive = await ArchiveFactory.OpenAsyncArchive(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
+ await using (archive.ConfigureAwait(false))
+ {
+ // throw exception to log results if no cover is found
+ (var cover, imageFormat) = await FindCoverEntryInArchiveAsync(archive).ConfigureAwait(false)
+ ?? throw new InvalidOperationException("no supported cover found");
+
+ // copy the cover to memory stream
+ var coverStream = await cover.OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false);
+ await using (coverStream.ConfigureAwait(false))
+ {
+ await coverStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ // reset stream position after copying
+ memoryStream.Position = 0;
+
+ return new DynamicImageResponse { HasImage = true, Stream = memoryStream, Format = imageFormat };
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "failed to load cover from {Path}", item.Path);
+ return new DynamicImageResponse { HasImage = false };
+ }
+ }
+
+ /// <summary>
+ /// Tries to find the entry containing the cover.
+ /// </summary>
+ /// <param name="archive">The archive to search.</param>
+ /// <returns>The search result.</returns>
+ private async ValueTask<(IArchiveEntry CoverEntry, ImageFormat ImageFormat)?> FindCoverEntryInArchiveAsync(IAsyncArchive archive)
+ {
+ IArchiveEntry? cover;
+
+ // only some comics will explicitly name their cover file
+ // in many cases the cover will simply be the first image in the archive
+ foreach (var extension in _coverExtensions)
+ {
+ cover = await archive.EntriesAsync.FirstOrDefaultAsync(e => e.Key == "cover" + extension).ConfigureAwait(false);
+
+ if (cover is not null)
+ {
+ var imageFormat = GetImageFormat(extension);
+
+ return (cover, imageFormat);
+ }
+ }
+
+ cover = await archive.EntriesAsync.OrderBy(x => x.Key)
+ .FirstOrDefaultAsync(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+
+ if (cover is not null)
+ {
+ var imageFormat = GetImageFormat(Path.GetExtension(cover.Key ?? string.Empty));
+
+ return (cover, imageFormat);
+ }
+
+ return null;
+ }
+
+ private static ImageFormat GetImageFormat(string extension) => extension.ToLowerInvariant() switch
+ {
+ ".jpg" => ImageFormat.Jpg,
+ ".jpeg" => ImageFormat.Jpg,
+ ".png" => ImageFormat.Png,
+ ".webp" => ImageFormat.Webp,
+ ".bmp" => ImageFormat.Bmp,
+ ".gif" => ImageFormat.Gif,
+ ".svg" => ImageFormat.Svg,
+ _ => throw new ArgumentException($"unsupported extension: {extension}"),
+ };
+}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs
new file mode 100644
index 0000000000..4e8dc405ec
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/ComicInfoReader.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+
+namespace MediaBrowser.Providers.Books.ComicInfo;
+
+/// <summary>
+/// ComicInfo reader.
+/// </summary>
+public static class ComicInfoReader
+{
+ /// <summary>
+ /// Filename to check for comic metadata either next to the comic file or inside the archive.
+ /// </summary>
+ public const string ComicRackMetaFile = "ComicInfo.xml";
+
+ /// <summary>
+ /// Read comic book metadata.
+ /// </summary>
+ /// <param name="xml">The XDocument to read for comic metadata.</param>
+ /// <returns>The resulting book.</returns>
+ public static Book? ReadComicBookMetadata(XDocument xml)
+ {
+ var book = new Book();
+ var hasFoundMetadata = false;
+
+ // this value is only used internally since Jellyfin has no manga flag
+ var isManga = false;
+
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Title", title => book.Name = title);
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Manga", manga => isManga = manga.Equals("Yes", StringComparison.OrdinalIgnoreCase));
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Series", series => book.SeriesName = series);
+ hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Number", issue => book.IndexNumber = issue);
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Summary", summary => book.Overview = summary);
+ hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Year", year => book.ProductionYear = year);
+ hasFoundMetadata |= ReadThreePartDateInto(xml, "ComicInfo/Year", "ComicInfo/Month", "ComicInfo/Day", dateTime => book.PremiereDate = dateTime);
+ hasFoundMetadata |= ReadCommaSeparatedStringsInto(xml, "ComicInfo/Genre", genres =>
+ {
+ foreach (var genre in genres)
+ {
+ book.AddGenre(genre);
+ }
+ });
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Publisher", publisher => book.SetStudios([publisher]));
+
+ hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/AlternateSeries", title =>
+ {
+ if (isManga)
+ {
+ // Software like ComicTagger (https://github.com/comictagger/comictagger) will use
+ // this field for the series name in the original language when tagging manga.
+ book.OriginalTitle = title;
+ }
+ else
+ {
+ // Some US comics can be part of cross-over story arcs. This field is then used to
+ // specify an alternate series.
+ }
+ });
+
+ return hasFoundMetadata ? book : null;
+ }
+
+ /// <summary>
+ /// Read people metadata.
+ /// </summary>
+ /// <param name="xml">The XDocument to read for people metadata.</param>
+ /// <param name="metadataResult">The metadata result to update.</param>
+ public static void ReadPeopleMetadata(XDocument xml, MetadataResult<Book> metadataResult)
+ {
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Writer", authors =>
+ {
+ foreach (var p in authors)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Author });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Penciller", pencillers =>
+ {
+ foreach (var p in pencillers)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Penciller });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Inker", inkers =>
+ {
+ foreach (var p in inkers)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Inker });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Letterer", letterers =>
+ {
+ foreach (var p in letterers)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Letterer });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/CoverArtist", artists =>
+ {
+ foreach (var p in artists)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.CoverArtist });
+ }
+ });
+
+ ReadCommaSeparatedStringsInto(xml, "ComicInfo/Colourist", colorists =>
+ {
+ foreach (var p in colorists)
+ {
+ metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Colorist });
+ }
+ });
+ }
+
+ /// <summary>
+ /// Read culture information.
+ /// </summary>
+ /// <param name="xml">the XDocument to read for metadata.</param>
+ /// <param name="xPath">The path to search.</param>
+ /// <param name="commitResult">The action to take after parsing all metadata.</param>
+ public static void ReadCultureInfoInto(XDocument xml, string xPath, Action<CultureInfo> commitResult)
+ {
+ string? culture = null;
+
+ if (!ReadStringInto(xml, xPath, value => culture = value))
+ {
+ return;
+ }
+
+ // culture cannot be null here as the method would have returned earlier
+ commitResult(new CultureInfo(culture!));
+ }
+
+ private static bool ReadStringInto(XDocument xml, string xPath, Action<string> commitResult)
+ {
+ var resultElement = xml.XPathSelectElement(xPath);
+
+ if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value))
+ {
+ commitResult(resultElement.Value);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool ReadCommaSeparatedStringsInto(XDocument xml, string xPath, Action<IEnumerable<string>> commitResult)
+ {
+ var resultElement = xml.XPathSelectElement(xPath);
+
+ if (resultElement is null || string.IsNullOrWhiteSpace(resultElement.Value))
+ {
+ return false;
+ }
+
+ try
+ {
+ var splits = resultElement.Value.Split(",").Select(p => p.Trim()).ToArray();
+ if (splits.Length < 1)
+ {
+ return false;
+ }
+
+ commitResult(splits);
+ return true;
+ }
+ catch (ArgumentNullException)
+ {
+ return false;
+ }
+ }
+
+ private static bool ReadIntInto(XDocument xml, string xPath, Action<int> commitResult)
+ {
+ var resultElement = xml.XPathSelectElement(xPath);
+
+ if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value))
+ {
+ return ParseInt(resultElement.Value, commitResult);
+ }
+
+ return false;
+ }
+
+ private static bool ReadThreePartDateInto(XDocument xml, string yearXPath, string monthXPath, string dayXPath, Action<DateTime> commitResult)
+ {
+ int year = 0;
+ int month = 0;
+ int day = 0;
+ var parsed = false;
+
+ parsed |= ReadIntInto(xml, yearXPath, num => year = num);
+ parsed |= ReadIntInto(xml, monthXPath, num => month = num);
+ parsed |= ReadIntInto(xml, dayXPath, num => day = num);
+
+ if (!parsed)
+ {
+ return false;
+ }
+
+ try
+ {
+ var dateTime = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Unspecified);
+
+ commitResult(dateTime);
+ return true;
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return false;
+ }
+ }
+
+ private static bool ParseInt(string input, Action<int> commitResult)
+ {
+ if (int.TryParse(input, out var parsed))
+ {
+ commitResult(parsed);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
new file mode 100644
index 0000000000..02cc02b7f3
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs
@@ -0,0 +1,99 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.ComicInfo;
+
+/// <summary>
+/// Handles metadata for comics which is saved as an XML document. This XML document is not part
+/// of the comic itself but an external file.
+/// </summary>
+public class ExternalComicInfoProvider : IComicProvider
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger<ExternalComicInfoProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ExternalComicInfoProvider"/> class.
+ /// </summary>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{ExternalComicInfoProvider}"/> interface.</param>
+ public ExternalComicInfoProvider(IFileSystem fileSystem, ILogger<ExternalComicInfoProvider> logger)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ /// <inheritdoc />
+ public async ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false);
+
+ if (comicInfoXml is null)
+ {
+ _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file.", info.Path);
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml);
+
+ if (book is null)
+ {
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
+
+ ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+
+ return metadataResult;
+ }
+
+ /// <inheritdoc />
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetXmlFilePath(item.Path);
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken)
+ {
+ var path = GetXmlFilePath(info.Path).FullName;
+
+ if (path is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ using var reader = XmlReader.Create(path, new XmlReaderSettings { Async = true });
+ var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken);
+
+ return await comicInfoXml.ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ _logger.LogInformation(e, "Could not load external XML from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path);
+ return null;
+ }
+ }
+
+ private FileSystemMetadata GetXmlFilePath(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+ var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!);
+ var file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".xml"));
+
+ return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, ComicInfoReader.ComicRackMetaFile));
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
new file mode 100644
index 0000000000..98a6aba7d6
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs
@@ -0,0 +1,120 @@
+using System;
+using System.IO.Compression;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Providers.Books.ComicInfo;
+
+/// <summary>
+/// Handles metadata for comics which is saved as an XML document inside the comic itself.
+/// </summary>
+public class InternalComicInfoProvider : IComicProvider
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger<InternalComicInfoProvider> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="InternalComicInfoProvider"/> class.
+ /// </summary>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{InternalComicInfoProvider}"/> interface.</param>
+ public InternalComicInfoProvider(IFileSystem fileSystem, ILogger<InternalComicInfoProvider> logger)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
+
+ /// <inheritdoc />
+ public async ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false);
+
+ if (comicInfoXml is null)
+ {
+ _logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml);
+
+ if (book is null)
+ {
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
+
+ ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
+ ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
+
+ return metadataResult;
+ }
+
+ /// <inheritdoc />
+ public bool HasItemChanged(BaseItem item)
+ {
+ var file = GetComicBookFile(item.Path);
+
+ if (file is null)
+ {
+ return false;
+ }
+
+ return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ }
+
+ private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken)
+ {
+ var path = GetComicBookFile(info.Path)?.FullName;
+
+ if (path is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ // open the comic archive and try to get the ComicInfo.xml entry
+ using var comicBookFile = await ZipFile.OpenReadAsync(path, cancellationToken).ConfigureAwait(false);
+ var container = comicBookFile.GetEntry(ComicInfoReader.ComicRackMetaFile);
+
+ if (container is null)
+ {
+ return null;
+ }
+
+ using var containerStream = await container.OpenAsync(cancellationToken).ConfigureAwait(false);
+ var comicInfoXml = XDocument.LoadAsync(containerStream, LoadOptions.None, cancellationToken);
+
+ return await comicInfoXml.ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ _logger.LogError(e, "could not load internal XML from {Path}", path);
+ return null;
+ }
+ }
+
+ private FileSystemMetadata? GetComicBookFile(string path)
+ {
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+
+ if (fileInfo.IsDirectory)
+ {
+ return null;
+ }
+
+ // only parse files that are known to have internal metadata
+ if (!string.Equals(fileInfo.Extension, ".cbz", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return fileInfo;
+ }
+}
diff --git a/MediaBrowser.Providers/Books/ComicProvider.cs b/MediaBrowser.Providers/Books/ComicProvider.cs
new file mode 100644
index 0000000000..d59c58c330
--- /dev/null
+++ b/MediaBrowser.Providers/Books/ComicProvider.cs
@@ -0,0 +1,59 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+
+namespace MediaBrowser.Providers.Books;
+
+/// <summary>
+/// Comic provider.
+/// </summary>
+public class ComicProvider : ILocalMetadataProvider<Book>, IHasItemChangeMonitor
+{
+ private readonly IEnumerable<IComicProvider> _comicProviders;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ComicProvider"/> class.
+ /// </summary>
+ /// <param name="comicProviders">The list of comic providers.</param>
+ public ComicProvider(IEnumerable<IComicProvider> comicProviders)
+ {
+ _comicProviders = comicProviders;
+ }
+
+ /// <inheritdoc />
+ public string Name => "Comic Provider";
+
+ /// <inheritdoc />
+ public async Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
+ {
+ foreach (IComicProvider comicProvider in _comicProviders)
+ {
+ var metadata = await comicProvider.ReadMetadata(info, directoryService, cancellationToken).ConfigureAwait(false);
+
+ if (metadata.HasMetadata)
+ {
+ return metadata;
+ }
+ }
+
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
+
+ /// <inheritdoc />
+ public bool HasChanged(BaseItem item, IDirectoryService directoryService)
+ {
+ foreach (IComicProvider iComicFileProvider in _comicProviders)
+ {
+ var fileChanged = iComicFileProvider.HasItemChanged(item);
+
+ if (fileChanged)
+ {
+ return fileChanged;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/MediaBrowser.Providers/Books/IComicProvider.cs b/MediaBrowser.Providers/Books/IComicProvider.cs
new file mode 100644
index 0000000000..06c8bd1136
--- /dev/null
+++ b/MediaBrowser.Providers/Books/IComicProvider.cs
@@ -0,0 +1,28 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+
+namespace MediaBrowser.Providers.Books;
+
+/// <summary>
+/// Comic provider interface.
+/// </summary>
+public interface IComicProvider
+{
+ /// <summary>
+ /// Read the item metadata.
+ /// </summary>
+ /// <param name="info">The item information.</param>
+ /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The metadata result.</returns>
+ ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Determine whether the item has changed.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <returns>Item change status.</returns>
+ bool HasItemChanged(BaseItem item);
+}
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/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..60eda1628c 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;
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index 1032582900..df51dd8421 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -22,6 +22,7 @@
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="PlaylistsNET" />
+ <PackageReference Include="SharpCompress" />
<PackageReference Include="z440.atl.core" />
<PackageReference Include="TMDbLib" />
</ItemGroup>
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/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
index f1582febf2..1d3a273354 100644
--- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
+++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
@@ -102,7 +102,8 @@ namespace MediaBrowser.Providers.MediaInfo
DtoOptions = new DtoOptions(true),
SourceTypes = new[] { SourceType.Library },
Parent = library,
- Recursive = true
+ Recursive = true,
+ IncludeOwnedItems = true
};
if (skipIfAudioTrackMatches)
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
index 49ece22a98..0acd44afbe 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
@@ -142,7 +142,9 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
if (string.IsNullOrWhiteSpace(overview))
{
- overview = result.strDescriptionEN;
+ overview = string.IsNullOrWhiteSpace(result.strDescriptionEN)
+ ? result.strDescription
+ : result.strDescriptionEN;
}
item.Overview = (overview ?? string.Empty).StripHtml();
@@ -240,6 +242,8 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
public string strAlbumCDart { get; set; }
+ public string strDescription { get; set; }
+
public string strDescriptionEN { get; set; }
public string strDescriptionDE { get; set; }
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 1eb411f0f6..b44e54f888 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();
@@ -417,6 +426,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 078c396730..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,7 +297,30 @@ 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();
var physicalSeasonIds = seasons
.Where(e => e.LocationType != LocationType.Virtual)
@@ -261,11 +346,12 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
if (existingSeason is null)
{
var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
- await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
+ var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
+ seasons.Add(season);
}
else if (existingSeason.IsVirtualItem)
{
- var episodeCount = seriesChildren.OfType<Episode>().Count(e => e.ParentIndexNumber == seasonNumber && !e.IsMissingEpisode);
+ var episodeCount = episodes.Count(e => e.ParentIndexNumber == seasonNumber && !e.IsMissingEpisode);
if (episodeCount > 0)
{
existingSeason.IsVirtualItem = false;
@@ -273,6 +359,21 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
}
}
}
+
+ // Loop through episodes
+ foreach (var episode in episodes)
+ {
+ var season = seasons.FirstOrDefault(i => i.IndexNumber == episode.ParentIndexNumber);
+ if (season is null || episode.SeasonId.Equals(season.Id))
+ {
+ continue;
+ }
+
+ // Assign the correct season id and name to episode.
+ episode.SeasonId = season.Id;
+ episode.SeasonName = season.Name;
+ await episode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
+ }
}
/// <summary>
@@ -283,7 +384,7 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
/// <param name="seasonNumber">The season number.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The newly created season.</returns>
- private async Task CreateSeasonAsync(
+ private async Task<Season> CreateSeasonAsync(
Series series,
string? seasonName,
int? seasonNumber,
@@ -306,6 +407,8 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
series.AddChild(season);
await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false);
+
+ return season;
}
private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
index f386e882e2..1af7460540 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
@@ -112,6 +112,92 @@ public static class JellyfinQueryHelperExtensions
}
/// <summary>
+ /// Filters items that match any of the specified (provider name, value) pairs.
+ /// </summary>
+ /// <param name="baseQuery">The source query.</param>
+ /// <param name="providerIds">Dictionary mapping provider names to arrays of values to match.</param>
+ /// <returns>A filtered query.</returns>
+ public static IQueryable<BaseItemEntity> WhereHasAnyProviderIds(
+ this IQueryable<BaseItemEntity> baseQuery,
+ IReadOnlyDictionary<string, string[]> providerIds)
+ {
+ var providerKeys = providerIds
+ .SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
+ .ToList();
+
+ if (providerKeys.Count == 0)
+ {
+ return baseQuery;
+ }
+
+ return baseQuery.Where(e => e.Provider!.Any(p => providerKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
+ }
+
+ /// <summary>
+ /// Filters items that have any of the specified providers. Empty/null values match any value for that provider.
+ /// </summary>
+ /// <param name="baseQuery">The source query.</param>
+ /// <param name="providerIds">Dictionary mapping provider names to optional values.</param>
+ /// <returns>A filtered query.</returns>
+ public static IQueryable<BaseItemEntity> WhereHasAnyProviderId(
+ this IQueryable<BaseItemEntity> baseQuery,
+ IReadOnlyDictionary<string, string> providerIds)
+ {
+ var existenceOnly = providerIds
+ .Where(e => string.IsNullOrEmpty(e.Value))
+ .Select(e => e.Key)
+ .ToList();
+
+ var specificValues = providerIds
+ .Where(e => !string.IsNullOrEmpty(e.Value))
+ .Select(e => $"{e.Key}:{e.Value}")
+ .ToList();
+
+ if (existenceOnly.Count == 0 && specificValues.Count == 0)
+ {
+ return baseQuery;
+ }
+
+ if (existenceOnly.Count == 0)
+ {
+ return baseQuery.Where(e => e.Provider!.Any(p =>
+ specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
+ }
+
+ if (specificValues.Count == 0)
+ {
+ return baseQuery.Where(e => e.Provider!.Any(p => existenceOnly.Contains(p.ProviderId)));
+ }
+
+ // Single EXISTS over Provider with both predicates OR'd, instead of two separate subqueries.
+ return baseQuery.Where(e => e.Provider!.Any(p =>
+ existenceOnly.Contains(p.ProviderId) ||
+ specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
+ }
+
+ /// <summary>
+ /// Excludes items that match any of the specified (provider name, value) pairs.
+ /// </summary>
+ /// <param name="baseQuery">The source query.</param>
+ /// <param name="providerIds">Dictionary mapping provider names to values to exclude.</param>
+ /// <returns>A filtered query.</returns>
+ public static IQueryable<BaseItemEntity> WhereExcludeProviderIds(
+ this IQueryable<BaseItemEntity> baseQuery,
+ IReadOnlyDictionary<string, string> providerIds)
+ {
+ var excludeKeys = providerIds
+ .Select(e => $"{e.Key}:{e.Value}")
+ .ToList();
+
+ if (excludeKeys.Count == 0)
+ {
+ return baseQuery;
+ }
+
+ return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
+ }
+
+ /// <summary>
/// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query.
/// </summary>
/// <typeparam name="TEntity">The entity.</typeparam>
@@ -138,9 +224,10 @@ public static class JellyfinQueryHelperExtensions
var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
- if (oneOf.Count < 4) // arbitrary value choosen.
+ // Threshold picked from microbenchmarks on SQLite: inline IN(const,...) beats a
+ // parameterized array lookup by ~5-10% up to ~32 elements.
+ if (oneOf.Count <= 32)
{
- // if we have 3 or fewer values to check against its faster to do a IN(const,const,const) lookup
return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter);
}
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 556516674b..b8545cbb64 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
+using Jellyfin.LiveTv;
using Jellyfin.LiveTv.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Dto;
@@ -448,18 +449,9 @@ public class GuideManager : IGuideManager
item.Name = channelInfo.Name;
- if (!item.HasImage(ImageType.Primary))
+ if (LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(item, channelInfo.ImagePath, channelInfo.ImageUrl))
{
- if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
- {
- item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
- forceUpdate = true;
- }
- else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
- {
- item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
- forceUpdate = true;
- }
+ forceUpdate = true;
}
if (isNew)
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..d456bea469 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
@@ -748,9 +748,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 ec2e6cfcc9..7088d1f0bf 100644
--- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
@@ -12,6 +12,7 @@ using System.Threading.Tasks;
using Jellyfin.Extensions;
using Jellyfin.XmlTv;
using Jellyfin.XmlTv.Entities;
+using Jellyfin.XmlTv.Enums;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
@@ -180,6 +181,8 @@ namespace Jellyfin.LiveTv.Listings
string? episodeTitle = program.Episode?.Title;
var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList();
var imageUrl = program.Icons.FirstOrDefault()?.Source;
+ var episodeImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Still)?.Path;
+ var backgroundImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Backdrop)?.Path;
var rating = program.Ratings.FirstOrDefault()?.Value;
var starRating = program.StarRatings?.FirstOrDefault()?.StarRating;
@@ -205,6 +208,8 @@ namespace Jellyfin.LiveTv.Listings
IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCase)),
ImageUrl = string.IsNullOrEmpty(imageUrl) ? null : imageUrl,
HasImage = !string.IsNullOrEmpty(imageUrl),
+ BackdropImageUrl = string.IsNullOrEmpty(backgroundImageUrl) ? null : backgroundImageUrl,
+ ThumbImageUrl = string.IsNullOrEmpty(episodeImageUrl) ? null : episodeImageUrl,
OfficialRating = string.IsNullOrEmpty(rating) ? null : rating,
CommunityRating = starRating is null ? null : (float)starRating.Value,
SeriesId = program.Episode?.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture)
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.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
index fcf37f35d7..6d3ae56f56 100644
--- a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
+++ b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
@@ -60,6 +60,7 @@ public class KeyframeExtractionScheduledTask : IScheduledTask
DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library],
Recursive = true,
+ IncludeOwnedItems = true,
Limit = Pagesize
};
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.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
new file mode 100644
index 0000000000..a003be4d96
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Globalization;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Helpers
+{
+ public class MediaInfoHelperTests
+ {
+ private static MediaInfoHelper CreateHelper()
+ {
+ return new MediaInfoHelper(
+ Mock.Of<IUserManager>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IMediaEncoder>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILogger<MediaInfoHelper>>(),
+ Mock.Of<INetworkManager>(),
+ Mock.Of<IDeviceManager>());
+ }
+
+ private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true)
+ {
+ return new MediaSourceInfo
+ {
+ Id = itemId.ToString("N", CultureInfo.InvariantCulture),
+ Protocol = MediaProtocol.File,
+ Bitrate = bitrate,
+ SupportsDirectPlay = supportsDirectPlay,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true
+ };
+ }
+
+ [Fact]
+ public void SortMediaSources_PreferredItemExceedsBitrate_StaysDefault()
+ {
+ // The version the user was watching (the queried item) must stay the default
+ // even when a sibling version fits the bitrate limit better, since the resume
+ // position belongs to that exact version.
+ var preferredItemId = Guid.NewGuid();
+ var preferredSource = CreateSource(preferredItemId, bitrate: 80_000_000, supportsDirectPlay: false);
+ var siblingSource = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
+
+ var result = new PlaybackInfoResponse
+ {
+ MediaSources = [siblingSource, preferredSource]
+ };
+
+ CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, preferredItemId);
+
+ Assert.Equal(preferredSource.Id, result.MediaSources[0].Id);
+ }
+
+ [Fact]
+ public void SortMediaSources_NoPreferredItem_OrdersByPlayability()
+ {
+ var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
+ var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
+ transcodeOnly.SupportsDirectStream = false;
+
+ var result = new PlaybackInfoResponse
+ {
+ MediaSources = [transcodeOnly, directPlay]
+ };
+
+ CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000);
+
+ Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
+ }
+
+ [Fact]
+ public void SortMediaSources_PreferredIdNotInSources_KeepsPlayabilityOrder()
+ {
+ var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
+ var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
+ transcodeOnly.SupportsDirectStream = false;
+
+ var result = new PlaybackInfoResponse
+ {
+ MediaSources = [transcodeOnly, directPlay]
+ };
+
+ CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, Guid.NewGuid());
+
+ Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
+ }
+ }
+}
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.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.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/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/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/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
new file mode 100644
index 0000000000..96625ae670
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
@@ -0,0 +1,137 @@
+using System;
+using Emby.Server.Implementations.Dto;
+using Emby.Server.Implementations.Playlists;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Trickplay;
+using MediaBrowser.Model.Entities;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Dto;
+
+public class DtoServiceImageInheritanceTests
+{
+ [Fact]
+ public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage()
+ {
+ var displayParent = new PlaylistsFolder
+ {
+ Id = Guid.NewGuid(),
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/playlists-custom.jpg",
+ DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var userView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ ViewType = CollectionType.playlists,
+ DisplayParentId = displayParent.Id,
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/generated.png",
+ DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var dtoService = BuildDtoService(displayParent);
+
+ var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
+
+ Assert.NotNull(dto.ParentPrimaryImageItemId);
+ Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId);
+ Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag);
+ Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary));
+ }
+
+ [Fact]
+ public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage()
+ {
+ var displayParent = new PlaylistsFolder
+ {
+ Id = Guid.NewGuid(),
+ ImageInfos = []
+ };
+
+ var userView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ ViewType = CollectionType.playlists,
+ DisplayParentId = displayParent.Id,
+ ImageInfos =
+ [
+ new ItemImageInfo
+ {
+ Type = ImageType.Primary,
+ Path = "/images/generated.png",
+ DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
+ }
+ ]
+ };
+
+ var dtoService = BuildDtoService(displayParent);
+
+ var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
+
+ Assert.Null(dto.ParentPrimaryImageItemId);
+ Assert.Null(dto.ParentPrimaryImageTag);
+ Assert.NotNull(dto.ImageTags);
+ Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
+ Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]);
+ }
+
+ private static DtoService BuildDtoService(BaseItem displayParent)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ var userDataManager = new Mock<IUserDataManager>();
+ var imageProcessor = new Mock<IImageProcessor>();
+ var providerManager = new Mock<IProviderManager>();
+ var recordingsManager = new Mock<IRecordingsManager>();
+ var appHost = new Mock<IApplicationHost>();
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ var liveTvManager = new Mock<ILiveTvManager>();
+ var trickplayManager = new Mock<ITrickplayManager>();
+ var chapterManager = new Mock<IChapterManager>();
+ var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>();
+
+ libraryManager
+ .Setup(x => x.GetItemById(displayParent.Id))
+ .Returns(displayParent);
+
+ imageProcessor
+ .Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
+ .Returns<BaseItem, ItemImageInfo>((_, image) => image.Path);
+
+ return new DtoService(
+ logger.Object,
+ libraryManager.Object,
+ userDataManager.Object,
+ imageProcessor.Object,
+ providerManager.Object,
+ recordingsManager.Object,
+ appHost.Object,
+ mediaSourceManager.Object,
+ new Lazy<ILiveTvManager>(() => liveTvManager.Object),
+ trickplayManager.Object,
+ chapterManager.Object);
+ }
+}
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/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/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/UserManagerLockHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs
new file mode 100644
index 0000000000..8149938b4d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerLockHelperTests.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Threading.Tasks;
+using Jellyfin.Server.Implementations.Users;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users
+{
+ public class UserManagerLockHelperTests
+ {
+ [Fact]
+ public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ var key = Guid.NewGuid();
+
+ Assert.True(helper.ShouldLock());
+
+ var outerHandle = await helper.LockAsync(key);
+ Assert.False(helper.ShouldLock());
+
+ var innerHandle = await helper.LockAsync(key);
+ Assert.False(helper.ShouldLock());
+
+ innerHandle.Dispose();
+ Assert.False(helper.ShouldLock());
+
+ outerHandle.Dispose();
+ Assert.True(helper.ShouldLock());
+ }
+
+ [Fact]
+ public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ var key = Guid.NewGuid();
+
+ var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondEntered = false;
+
+ var firstTask = Task.Run(
+ async () =>
+ {
+ using var firstHandle = await helper.LockAsync(key);
+ firstAcquired.SetResult(true);
+ await releaseFirst.Task;
+ },
+ TestContext.Current.CancellationToken);
+
+ await firstAcquired.Task;
+
+ var secondTask = Task.Run(
+ async () =>
+ {
+ using var secondHandle = await helper.LockAsync(key);
+ secondEntered = true;
+ },
+ TestContext.Current.CancellationToken);
+
+ await Task.Delay(100, TestContext.Current.CancellationToken);
+ Assert.False(secondEntered);
+
+ releaseFirst.SetResult(true);
+
+ await Task.WhenAll(firstTask, secondTask);
+ Assert.True(secondEntered);
+ }
+
+ [Fact]
+ public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+ helper.Dispose();
+
+ await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
+ }
+
+ [Fact]
+ public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
+ {
+ UserManager.LockHelper.IsNestedLock.Value = 0;
+ using var helper = new UserManager.LockHelper();
+
+ helper.Dispose();
+ var ex = Record.Exception(() => helper.Dispose());
+
+ Assert.Null(ex);
+ }
+ }
+}
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;
+ }
+}