aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/ISSUE_TEMPLATE/issue report.yml7
-rw-r--r--.github/workflows/project-automation.yml77
-rw-r--r--CONTRIBUTORS.md2
-rw-r--r--Directory.Packages.props2
-rw-r--r--Emby.Naming/Emby.Naming.csproj2
-rw-r--r--Emby.Naming/ExternalFiles/ExternalPathParser.cs9
-rw-r--r--Emby.Naming/TV/EpisodePathParser.cs6
-rw-r--r--Emby.Naming/TV/SeriesResolver.cs13
-rw-r--r--Emby.Naming/Video/VideoListResolver.cs42
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs7
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs140
-rw-r--r--Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs65
-rw-r--r--Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs68
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs31
-rw-r--r--Emby.Server.Implementations/IO/FileRefresher.cs27
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitor.cs39
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs13
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs175
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs15
-rw-r--r--Emby.Server.Implementations/Library/Search/SearchManager.cs14
-rw-r--r--Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs7
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs15
-rw-r--r--Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs29
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs41
-rw-r--r--Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs41
-rw-r--r--Emby.Server.Implementations/Library/Validators/PeopleValidator.cs129
-rw-r--r--Emby.Server.Implementations/Localization/Core/be.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/bs.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/fo.json22
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/lb.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/pa.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json11
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/th.json41
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ur_PK.json25
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json2
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs48
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/it.json22
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/no.json14
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs10
-rw-r--r--Emby.Server.Implementations/Plugins/PluginManager.cs87
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs4
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs9
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs5
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs4
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs4
-rw-r--r--Emby.Server.Implementations/SyncPlay/Group.cs143
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs82
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs47
-rw-r--r--Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs8
-rw-r--r--Jellyfin.Api/Controllers/FilterController.cs21
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs17
-rw-r--r--Jellyfin.Api/Controllers/PackageController.cs31
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs2
-rw-r--r--Jellyfin.Api/Controllers/UserViewsController.cs2
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs1
-rw-r--r--Jellyfin.Data/Enums/VideoRangeType.cs5
-rw-r--r--Jellyfin.Data/Jellyfin.Data.csproj2
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemMapper.cs36
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs98
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs51
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs24
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs49
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs96
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemCountService.cs415
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs96
-rw-r--r--Jellyfin.Server.Implementations/Item/NextUpService.cs23
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs64
-rw-r--r--Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs24
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs10
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs14
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs28
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs216
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs (renamed from Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs)2
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs44
-rw-r--r--Jellyfin.Server/Migrations/Stages/CodeMigration.cs75
-rw-r--r--Jellyfin.Server/Program.cs46
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupActivity.cs3
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLogger.cs31
-rw-r--r--Jellyfin.Server/Startup.cs2
-rw-r--r--MediaBrowser.Common/MediaBrowser.Common.csproj2
-rw-r--r--MediaBrowser.Controller/Dto/DtoOptions.cs14
-rw-r--r--MediaBrowser.Controller/Entities/AggregateFolder.cs18
-rw-r--r--MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs12
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs35
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs112
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs14
-rw-r--r--MediaBrowser.Controller/Entities/Movies/BoxSet.cs37
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs13
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs51
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs43
-rw-r--r--MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs152
-rw-r--r--MediaBrowser.Controller/LiveTv/ILiveTvManager.cs7
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj3
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs65
-rw-r--r--MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs4
-rw-r--r--MediaBrowser.Controller/Persistence/IItemCountService.cs12
-rw-r--r--MediaBrowser.Controller/Persistence/IItemRepository.cs7
-rw-r--r--MediaBrowser.Controller/Playlists/Playlist.cs20
-rw-r--r--MediaBrowser.Controller/Providers/DirectoryService.cs111
-rw-r--r--MediaBrowser.Controller/Providers/IDirectoryService.cs13
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs21
-rw-r--r--MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs6
-rw-r--r--MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs5
-rw-r--r--MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs4
-rw-r--r--MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs2
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs8
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs95
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs4
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs10
-rw-r--r--MediaBrowser.Model/Entities/MediaStream.cs30
-rw-r--r--MediaBrowser.Model/MediaBrowser.Model.csproj2
-rw-r--r--MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs11
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs8
-rw-r--r--MediaBrowser.Providers/Manager/ItemImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs9
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs65
-rw-r--r--MediaBrowser.Providers/MediaBrowser.Providers.csproj1
-rw-r--r--MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs89
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs25
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs60
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs9
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs10
-rwxr-xr-xMediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs9
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs8
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs33
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs171
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs7
-rw-r--r--MediaBrowser.Providers/TV/EpisodeMetadataService.cs25
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs67
-rw-r--r--SharedVersion.cs4
-rwxr-xr-xbump_version1
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs59
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs8
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs10
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs34
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs55
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs4
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs181
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs106
-rw-r--r--src/Jellyfin.Extensions/Jellyfin.Extensions.csproj2
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelManager.cs39
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs24
-rw-r--r--src/Jellyfin.LiveTv/Listings/ListingsManager.cs7
-rw-r--r--src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs66
-rw-r--r--src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs102
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs8
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs2
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs26
-rw-r--r--tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs210
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs60
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs106
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs97
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs84
-rw-r--r--tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs216
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs162
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs40
-rw-r--r--tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs100
-rw-r--r--tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs73
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs130
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs137
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs43
-rw-r--r--tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs72
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml10
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs103
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs104
-rw-r--r--tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs129
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs3
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs5
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs40
-rw-r--r--tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs143
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs41
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs155
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs128
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs140
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs113
-rw-r--r--tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs110
-rw-r--r--tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs75
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs180
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs94
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs58
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs123
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs78
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs90
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs57
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs35
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs79
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs215
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs103
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs121
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs218
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs219
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs923
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs191
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs103
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs85
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs77
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs114
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs138
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs7
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs145
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs55
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs77
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs247
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs117
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs126
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs79
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs22
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs93
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs30
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs149
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs422
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs111
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs23
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs42
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs36
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs26
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs112
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs202
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs216
-rw-r--r--tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs54
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs21
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo43
238 files changed, 12975 insertions, 1189 deletions
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml
index 7a00dedbff..12ee6411d3 100644
--- a/.github/ISSUE_TEMPLATE/issue report.yml
+++ b/.github/ISSUE_TEMPLATE/issue report.yml
@@ -87,12 +87,9 @@ body:
label: Jellyfin Server version
description: What version of Jellyfin are you using?
options:
+ - 12.1
+ - 12.0
- 10.11.11
- - 10.11.10
- - 10.11.9
- - 10.11.8
- - 10.11.7
- - 10.11.6
- Master
- Unstable
- Older*
diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml
index 9a9f3214a7..5d7dd09348 100644
--- a/.github/workflows/project-automation.yml
+++ b/.github/workflows/project-automation.yml
@@ -1,66 +1,29 @@
name: Project Automation
on:
- push:
- branches:
- - master
pull_request:
- issue_comment:
+ types: [opened, reopened, synchronize, ready_for_review, edited]
+ pull_request_review:
+ types: [submitted]
+ pull_request_target:
+ types: [closed]
+
+permissions:
+ contents: read
-permissions: {}
jobs:
- project:
- name: Project board
+ project-board:
+ name: Release project board
runs-on: ubuntu-latest
- if: ${{ github.repository == 'jellyfin/jellyfin' }}
+ if: ${{ github.repository == 'jellyfin/jellyfin' && (github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository) }}
steps:
- - name: Remove from 'Current Release' project
- uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
- if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
- continue-on-error: true
- with:
- project: Current Release
- action: delete
- column: In progress
- repo-token: ${{ secrets.JF_BOT_TOKEN }}
-
- - name: Add to 'Release Next' project
- uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
- if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened'
- continue-on-error: true
- with:
- project: Release Next
- column: In progress
- repo-token: ${{ secrets.JF_BOT_TOKEN }}
-
- - name: Add to 'Current Release' project
- uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
- if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
- continue-on-error: true
- with:
- project: Current Release
- column: In progress
- repo-token: ${{ secrets.JF_BOT_TOKEN }}
-
- - name: Check number of comments from the team member
- if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER'
- id: member_comments
- run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)"
-
- - name: Move issue to needs triage
- uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
- if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1
- continue-on-error: true
- with:
- project: Issue Triage for Main Repo
- column: Needs triage
- repo-token: ${{ secrets.JF_BOT_TOKEN }}
-
- - name: Add issue to triage project
- uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
- if: github.event.issue.pull_request == '' && github.event.action == 'opened'
- continue-on-error: true
+ - name: Update project board
+ uses: jellyfin/jellyfin-packaging/.github/actions/project-automation@master
with:
- project: Issue Triage for Main Repo
- column: Pending response
- repo-token: ${{ secrets.JF_BOT_TOKEN }}
+ github-token: ${{ secrets.GH_PROJECTS_TOKEN }}
+ repo: jellyfin
+ event-name: ${{ github.event_name }}
+ base-ref: ${{ github.event.pull_request.base.ref }}
+ pr-number: ${{ github.event.pull_request.number }}
+ pr-merged: ${{ github.event.pull_request.merged }}
+ required-approvals: '2'
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 99f24e3a14..f5f844bcbc 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -138,6 +138,7 @@
- [SegiH](https://github.com/SegiH)
- [SenorSmartyPants](https://github.com/SenorSmartyPants)
- [shemanaev](https://github.com/shemanaev)
+ - [SimonvBez](https://github.com/SimonvBez)
- [skaro13](https://github.com/skaro13)
- [sl1288](https://github.com/sl1288)
- [Smith00101010](https://github.com/Smith00101010)
@@ -240,6 +241,7 @@
- [Florin-Popescu](https://github.com/Florin-Popescu)
- [m0g3r](https://github.com/m0g3r)
- [martin-77](https://github.com/martin-77)
+ - [Oggeb1](https://github.com/Oggeb1)
# Emby Contributors
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 4d5f0efa9e..38cf513124 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,7 +9,7 @@
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="BDInfo" Version="0.8.0" />
- <PackageVersion Include="BitFaster.Caching" Version="2.6.0" />
+ <PackageVersion Include="BitFaster.Caching" Version="2.6.1" />
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj
index 9f98970df5..acf43a69c2 100644
--- a/Emby.Naming/Emby.Naming.csproj
+++ b/Emby.Naming/Emby.Naming.csproj
@@ -36,7 +36,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Naming</PackageId>
- <VersionPrefix>12.0.0</VersionPrefix>
+ <VersionPrefix>13.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
index 8e7da5db42..1f16161282 100644
--- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs
+++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
@@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles
}
var extension = Path.GetExtension(path.AsSpan());
- if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
+
+ // .idx carries VobSub per-track language metadata. Recognize it here rather
+ // than adding it to NamingOptions.SubtitleFileExtensions, which also gates
+ // subtitle uploads/saves.
+ var isVobSubIndex = _type == DlnaProfileType.Subtitle && extension.Equals(".idx", StringComparison.OrdinalIgnoreCase);
+
+ if (!isVobSubIndex
+ && !(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
&& !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
&& !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
{
diff --git a/Emby.Naming/TV/EpisodePathParser.cs b/Emby.Naming/TV/EpisodePathParser.cs
index 0c737964b4..f06aa909ba 100644
--- a/Emby.Naming/TV/EpisodePathParser.cs
+++ b/Emby.Naming/TV/EpisodePathParser.cs
@@ -158,7 +158,9 @@ namespace Emby.Naming.TV
if (nextIndex >= name.Length
|| !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal))
{
- if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
+ // A range cannot end before it starts, so a lower number belongs to the episode title rather than to a range.
+ if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)
+ && num >= result.EpisodeNumber)
{
result.EndingEpisodeNumber = num;
}
@@ -226,7 +228,7 @@ namespace Emby.Naming.TV
info.SeriesName = result.SeriesName;
}
- if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue)
+ if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber)
{
info.EndingEpisodeNumber = result.EndingEpisodeNumber;
}
diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs
index 733e2418c2..ce42cb69fb 100644
--- a/Emby.Naming/TV/SeriesResolver.cs
+++ b/Emby.Naming/TV/SeriesResolver.cs
@@ -10,11 +10,14 @@ namespace Emby.Naming.TV
public static partial class SeriesResolver
{
/// <summary>
- /// Regex that matches strings of at least 2 characters separated by a dot or underscore.
- /// Used for removing separators between words, i.e turns "The_show" into "The show" while
- /// preserving names like "S.H.O.W".
+ /// Regex that matches a run of dots or underscores that separates two words, where a word is
+ /// at least 2 characters long. Used for removing separators between words, i.e turns
+ /// "The_show" into "The show" while preserving acronyms like "S.H.O.W", whose single letters
+ /// are a word on neither side. Whitespace bounds a word too, so the dot in
+ /// "Marvel's Agents of S.H.I.E.L.D." is read against the "S" beside it rather than against
+ /// the whole run of words before it.
/// </summary>
- [GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")]
+ [GeneratedRegex(@"(?<=[^\s\._]{2})[\._]+|[\._]+(?=[^\s\._]{2})")]
private static partial Regex SeriesNameRegex();
/// <summary>
@@ -60,7 +63,7 @@ namespace Emby.Naming.TV
if (!string.IsNullOrEmpty(seriesName))
{
- seriesName = SeriesNameRegex().Replace(seriesName, "${a} ${b}").Trim();
+ seriesName = SeriesNameRegex().Replace(seriesName, " ").Trim();
}
return new SeriesInfo(path)
diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs
index 29330b132d..e16562774d 100644
--- a/Emby.Naming/Video/VideoListResolver.cs
+++ b/Emby.Naming/Video/VideoListResolver.cs
@@ -217,24 +217,7 @@ namespace Emby.Naming.Video
for (var i = 0; i < videos.Count; i++)
{
var video = videos[i];
- var episodeResult = _episodePathParser.Parse(video.Files[0].Path, false);
- string? key = null;
- if (episodeResult.Success)
- {
- if (episodeResult.IsByDate
- && episodeResult.Year.HasValue
- && episodeResult.Month.HasValue
- && episodeResult.Day.HasValue)
- {
- key = FormattableString.Invariant(
- $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}");
- }
- else if (episodeResult.EpisodeNumber.HasValue)
- {
- key = FormattableString.Invariant(
- $"S{episodeResult.SeasonNumber ?? 0}E{episodeResult.EpisodeNumber.Value}");
- }
- }
+ var key = GetEpisodeVersionKey(video.Files[0].Path);
if (key is null)
{
@@ -265,6 +248,29 @@ namespace Emby.Naming.Video
return result;
}
+ private string? GetEpisodeVersionKey(string path)
+ {
+ // Optimistic expressions are guesses, so they are not consulted here: merging is destructive,
+ // a file collapsed into the alternate versions of another one is no longer an episode of its own.
+ var episodeResult = _episodePathParser.Parse(path, false, isOptimistic: false, fillExtendedInfo: false);
+ if (!episodeResult.Success)
+ {
+ return null;
+ }
+
+ if (episodeResult.IsByDate)
+ {
+ return episodeResult.Year.HasValue && episodeResult.Month.HasValue && episodeResult.Day.HasValue
+ ? FormattableString.Invariant(
+ $"D{episodeResult.Year.Value}{episodeResult.Month.Value:D2}{episodeResult.Day.Value:D2}")
+ : null;
+ }
+
+ return episodeResult.SeasonNumber.HasValue && episodeResult.EpisodeNumber.HasValue
+ ? FormattableString.Invariant($"S{episodeResult.SeasonNumber.Value}E{episodeResult.EpisodeNumber.Value}")
+ : null;
+ }
+
private static VideoInfo OrganizeAlternateVersions(
List<VideoInfo> videos,
VideoInfo? primaryOverride = null,
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
index 84d50f5121..cd93f76d26 100644
--- a/Emby.Server.Implementations/Collections/CollectionManager.cs
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -9,6 +9,7 @@ using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Collections;
+using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
@@ -114,7 +115,7 @@ namespace Emby.Server.Implementations.Collections
_libraryManager.RootFolder.Children = null;
- return FindFolders(path).First();
+ return FindFolders(path).FirstOrDefault();
}
internal string GetCollectionsFolderPath()
@@ -167,7 +168,7 @@ namespace Emby.Server.Implementations.Collections
if (parentFolder is null)
{
- throw new ArgumentException(nameof(parentFolder));
+ throw new InvalidOperationException("Unable to resolve the collections library folder, so the collection cannot be created.");
}
var path = Path.Combine(parentFolder.Path, folderName);
@@ -235,7 +236,7 @@ namespace Emby.Server.Implementations.Collections
List<BaseItem>? itemList = null;
- var linkedChildrenList = collection.GetLinkedChildren();
+ var linkedChildrenList = collection.GetLinkedChildren(DtoOptions.StoredColumnsOnly);
var currentLinkedChildrenIds = linkedChildrenList.Select(i => i.Id).ToList();
foreach (var id in ids)
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index a2d3e14439..e539508644 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
+using Jellyfin.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
@@ -185,15 +186,18 @@ namespace Emby.Server.Implementations.Dto
allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();
}
- // Batch-fetch child counts for all folders to avoid N+1 queries
+ // Batch-fetch by-name item counts to avoid N+1 queries
+ Dictionary<Guid, ItemCounts>? itemCountsBatch = null;
+ if (options.ContainsField(ItemFields.ItemCounts))
+ {
+ itemCountsBatch = GetItemCountsBatch(accessibleItems, user);
+ }
+
+ // Batch-fetch child counts for all folders to avoid N+1 queries.
Dictionary<Guid, int>? childCountBatch = null;
- if (options.ContainsField(ItemFields.ChildCount))
+ if (user is not null && options.ContainsField(ItemFields.ChildCount))
{
- var folderIds = accessibleItems.OfType<Folder>().Select(f => f.Id).ToList();
- if (folderIds.Count > 0)
- {
- childCountBatch = _libraryManager.GetChildCountBatch(folderIds, user);
- }
+ childCountBatch = GetChildCountBatch(accessibleItems, user);
}
// Batch-fetch played/total counts for all folders to avoid N+1 queries
@@ -293,7 +297,7 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.ItemCounts))
{
- SetItemByNameInfo(dto, user);
+ SetItemByNameInfo(dto, user, itemCountsBatch);
}
returnItems[index] = dto;
@@ -518,14 +522,36 @@ namespace Emby.Server.Implementations.Dto
return dto;
}
- private void SetItemByNameInfo(BaseItemDto dto, User? user)
+ private Dictionary<Guid, ItemCounts> GetItemCountsBatch(IReadOnlyList<BaseItem> items, User? user)
+ {
+ var result = new Dictionary<Guid, ItemCounts>();
+
+ foreach (var group in items.GroupBy(item => item.GetBaseItemKind()))
+ {
+ if (!_relatedItemKinds.TryGetValue(group.Key, out var relatedItemKinds))
+ {
+ continue;
+ }
+
+ var ids = group.Select(item => item.Id).ToArray();
+ foreach (var (id, counts) in _libraryManager.GetItemCountsForNameItems(group.Key, ids, relatedItemKinds, user))
+ {
+ result[id] = counts;
+ }
+ }
+
+ return result;
+ }
+
+ private void SetItemByNameInfo(BaseItemDto dto, User? user, IReadOnlyDictionary<Guid, ItemCounts>? prefetchedCounts = null)
{
if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds))
{
return;
}
- var counts = _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user);
+ var counts = prefetchedCounts?.GetValueOrDefault(dto.Id)
+ ?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user);
dto.AlbumCount = counts.AlbumCount;
dto.ArtistCount = counts.ArtistCount;
@@ -685,24 +711,102 @@ namespace Emby.Server.Implementations.Dto
};
}
- private static int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch)
+ private Dictionary<Guid, int>? GetChildCountBatch(IReadOnlyList<BaseItem> items, User user)
{
- // Right now this is too slow to calculate for top level folders on a per-user basis
- // Just return something so that apps that are expecting a value won't think the folders are empty
- if (folder is ICollectionFolder || folder is UserView)
+ Dictionary<Guid, IReadOnlyList<Guid>>? sources = null;
+ foreach (var folder in items.OfType<Folder>())
+ {
+ var sourceIds = GetChildCountSourceIds(folder, user);
+ if (sourceIds.Count > 0)
+ {
+ (sources ??= new Dictionary<Guid, IReadOnlyList<Guid>>())[folder.Id] = sourceIds;
+ }
+ }
+
+ if (sources is null)
{
- return Random.Shared.Next(1, 10);
+ return null;
}
+ var counts = _libraryManager.GetChildCountBatch(
+ sources.Values.SelectMany(ids => ids).Distinct().ToList(),
+ user);
+
+ var result = new Dictionary<Guid, int>(sources.Count);
+ foreach (var (folderId, sourceIds) in sources)
+ {
+ var total = 0;
+ foreach (var sourceId in sourceIds)
+ {
+ total += counts.GetValueOrDefault(sourceId);
+ }
+
+ result[folderId] = total;
+ }
+
+ return result;
+ }
+
+ private IReadOnlyList<Guid> GetChildCountSourceIds(Folder folder, User user)
+ {
+ if (folder is CollectionFolder collectionFolder)
+ {
+ return collectionFolder.PhysicalFolderIds;
+ }
+
+ if (folder is not UserView view)
+ {
+ return [folder.Id];
+ }
+
+ // Only a view that stands for a library proxies it. The sub-views a movie or show view
+ // is built from hang off the same library but hold a query, not the library's children.
+ if (!UserView.EnableOriginalFolder(view.ViewType)
+ && view.ViewType is not (CollectionType.movies or CollectionType.tvshows))
+ {
+ return [];
+ }
+
+ // A view over a single library proxies that library, whatever the view type.
+ var parentId = view.DisplayParentId.IsEmpty() ? view.ParentId : view.DisplayParentId;
+ if (!parentId.IsEmpty()
+ && !parentId.Equals(view.Id)
+ && _libraryManager.GetItemById(parentId) is Folder parent
+ && parent is not UserView)
+ {
+ return GetChildCountSourceIds(parent, user);
+ }
+
+ // A grouped view has no single parent: it stands for every library the user grouped
+ // into it, the same set UserViewManager builds the view from.
+ if (view.ViewType is CollectionType.movies or CollectionType.tvshows)
+ {
+ return _libraryManager.GetUserRootFolder()
+ .GetChildren(user, true)
+ .OfType<CollectionFolder>()
+ .Where(f => user.IsFolderGrouped(f.Id)
+ && (f.CollectionType == view.ViewType || f.CollectionType is null))
+ .SelectMany(f => f.PhysicalFolderIds)
+ .Distinct()
+ .ToList();
+ }
+
+ return [];
+ }
+
+ private int GetChildCount(Folder folder, User user, Dictionary<Guid, int>? childCountBatch)
+ {
// Use pre-fetched batch data if available
if (childCountBatch is not null && childCountBatch.TryGetValue(folder.Id, out var count))
{
return count;
}
- // Only reached when no batch was computed: the batch holds an entry for every folder it
- // was asked about, zero included.
- return folder.GetChildCount(user);
+ // No batch covered this folder.
+ var single = GetChildCountBatch([folder], user);
+ return single is not null && single.TryGetValue(folder.Id, out var singleCount)
+ ? singleCount
+ : folder.GetChildCount(user);
}
private static void SetBookProperties(BaseItemDto dto, Book item)
diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
index 933cfc8cbe..02b104756e 100644
--- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
+++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
@@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints;
/// </summary>
public sealed class LibraryChangedNotifier : IHostedService, IDisposable
{
+ // A batch holds a live reference to every item it names, so it has to stay small enough that a
+ // library scan - which changes items faster than any batch window closes - cannot grow it without
+ // bound. Reached only by a scan; interactive use closes a batch on the window long before this.
+ internal const int MaxBatchSize = 2000;
+
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IProviderManager _providerManager;
@@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private readonly ILogger<LibraryChangedNotifier> _logger;
private readonly Lock _libraryChangedSyncLock = new();
- private readonly List<Folder> _foldersAddedTo = new();
- private readonly List<Folder> _foldersRemovedFrom = new();
- private readonly List<BaseItem> _itemsAdded = new();
- private readonly List<BaseItem> _itemsRemoved = new();
- private readonly List<BaseItem> _itemsUpdated = new();
+ private readonly Dictionary<Guid, Folder> _foldersAddedTo = [];
+ private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = [];
+ private readonly Dictionary<Guid, BaseItem> _itemsAdded = [];
+ private readonly Dictionary<Guid, BaseItem> _itemsRemoved = [];
+ private readonly Dictionary<Guid, BaseItem> _itemsUpdated = [];
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
private Timer? _libraryUpdateTimer;
@@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
=> OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom);
- private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList)
+ private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList)
{
if (!FilterItem(item))
{
@@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
lock (_libraryChangedSyncLock)
{
- var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
-
+ // The window runs from the first change of a batch and is never extended. Extending it on
+ // every change would keep a library scan's batch open for the whole scan, and the batch
+ // holds the items it names alive, so it would grow to the size of the library.
if (_libraryUpdateTimer is null)
{
+ var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
}
- else
- {
- _libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan);
- }
if (foldersList is not null && parent is Folder folder)
{
- foldersList.Add(folder);
+ foldersList[folder.Id] = folder;
}
- itemsList.Add(item);
+ itemsList[item.Id] = item;
+
+ // A window long enough to cover a burst still has to give way once the batch is large
+ // enough to be worth sending on its own.
+ if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize)
+ {
+ _libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
+ }
}
}
@@ -211,22 +221,16 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
List<BaseItem> itemsRemoved;
lock (_libraryChangedSyncLock)
{
- // Remove dupes in case some were saved multiple times
- foldersAddedTo = _foldersAddedTo
- .DistinctBy(x => x.Id)
- .ToList();
-
- foldersRemovedFrom = _foldersRemovedFrom
- .DistinctBy(x => x.Id)
- .ToList();
+ foldersAddedTo = _foldersAddedTo.Values.ToList();
+ foldersRemovedFrom = _foldersRemovedFrom.Values.ToList();
itemsUpdated = _itemsUpdated
- .Where(i => !_itemsAdded.Contains(i))
- .DistinctBy(x => x.Id)
+ .Where(e => !_itemsAdded.ContainsKey(e.Key))
+ .Select(e => e.Value)
.ToList();
- itemsAdded = _itemsAdded.ToList();
- itemsRemoved = _itemsRemoved.ToList();
+ itemsAdded = _itemsAdded.Values.ToList();
+ itemsRemoved = _itemsRemoved.Values.ToList();
if (_libraryUpdateTimer is not null)
{
@@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
_foldersRemovedFrom.Clear();
}
+ if (itemsAdded.Count == 0
+ && itemsUpdated.Count == 0
+ && itemsRemoved.Count == 0
+ && foldersAddedTo.Count == 0
+ && foldersRemovedFrom.Count == 0)
+ {
+ return;
+ }
+
await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false);
}
diff --git a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
index fc174b7c14..b182e5837b 100644
--- a/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
+++ b/Emby.Server.Implementations/EntryPoints/UserDataChangeNotifier.cs
@@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints
public sealed class UserDataChangeNotifier : IHostedService, IDisposable
{
private const int UpdateDuration = 500;
+ internal const int MaxBatchSize = 2000;
private readonly ISessionManager _sessionManager;
private readonly IUserDataManager _userDataManager;
private readonly IUserManager _userManager;
- private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new();
+ private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = [];
private readonly Lock _syncLock = new();
private Timer? _updateTimer;
+ private int _changedItemCount;
/// <summary>
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
@@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints
lock (_syncLock)
{
- if (_updateTimer is null)
+ // The window runs from the first change of a batch and is never extended, so a stream
+ // of changes that never pauses - a library scan - still closes its batches instead of
+ // holding every item it touched alive until the stream stops.
+ _updateTimer ??= new Timer(
+ UpdateTimerCallback,
+ null,
+ UpdateDuration,
+ Timeout.Infinite);
+
+ if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys))
{
- _updateTimer = new Timer(
- UpdateTimerCallback,
- null,
- UpdateDuration,
- Timeout.Infinite);
- }
- else
- {
- _updateTimer.Change(UpdateDuration, Timeout.Infinite);
- }
-
- if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys))
- {
- keys = new List<BaseItem>();
+ keys = [];
_changedItems[e.UserId] = keys;
}
- keys.Add(e.Item);
-
var baseItem = e.Item;
// Go up one level for indicators
if (baseItem is not null)
{
+ Track(keys, baseItem);
+
var parent = baseItem.GetOwner() ?? baseItem.GetParent();
if (parent is not null)
{
- keys.Add(parent);
+ Track(keys, parent);
}
}
+
+ // A window long enough to cover a burst still has to give way once the batch is
+ // large enough to be worth sending on its own.
+ if (_changedItemCount >= MaxBatchSize)
+ {
+ _updateTimer.Change(0, Timeout.Infinite);
+ }
+ }
+ }
+
+ private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item)
+ {
+ var before = keys.Count;
+ keys[item.Id] = item;
+
+ if (keys.Count != before)
+ {
+ _changedItemCount++;
}
}
private async void UpdateTimerCallback(object? state)
{
- List<KeyValuePair<Guid, List<BaseItem>>> changes;
+ List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes;
lock (_syncLock)
{
- // Remove dupes in case some were saved multiple times
changes = _changedItems.ToList();
_changedItems.Clear();
+ _changedItemCount = 0;
if (_updateTimer is not null)
{
@@ -121,17 +137,22 @@ namespace Emby.Server.Implementations.EntryPoints
}
}
+ if (changes.Count == 0)
+ {
+ return;
+ }
+
foreach (var (userId, changedItems) in changes)
{
await _sessionManager.SendMessageToUserSessions(
[userId],
SessionMessageType.UserDataChanged,
- () => GetUserDataChangeInfo(userId, changedItems),
+ () => GetUserDataChangeInfo(userId, changedItems.Values),
default).ConfigureAwait(false);
}
}
- private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems)
+ private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems)
{
var user = _userManager.GetUserById(userId)
?? throw new ArgumentException("Invalid user ID", nameof(userId));
@@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints
{
UserId = userId,
UserDataList = changedItems
- .DistinctBy(x => x.Id)
.Select(i =>
{
var dto = _userDataManager.GetUserDataDto(i, user);
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index dc7f972c13..d7319f80f4 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
@@ -127,7 +127,7 @@ namespace Emby.Server.Implementations.HttpServer
{
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
}
- catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException)
+ catch (Exception ex) when (IsConnectionGone(ex))
{
// ObjectDisposedException/OperationCanceledException: the socket was torn
// down underneath us (e.g. by the keep-alive watchdog after the connection
@@ -158,7 +158,15 @@ namespace Emby.Server.Implementations.HttpServer
if (receiveResult.EndOfMessage)
{
- await ProcessInternal(pipe.Reader).ConfigureAwait(false);
+ try
+ {
+ await ProcessInternal(pipe.Reader).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (IsConnectionGone(ex))
+ {
+ _logger.LogWarning("WS {IP} error sending data: {Message}", RemoteEndPoint, ex.Message);
+ break;
+ }
}
}
while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting)
@@ -170,13 +178,24 @@ namespace Emby.Server.Implementations.HttpServer
|| _socket.State == WebSocketState.CloseReceived
|| _socket.State == WebSocketState.CloseSent)
{
- await _socket.CloseAsync(
- WebSocketCloseStatus.NormalClosure,
- string.Empty,
- cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _socket.CloseAsync(
+ WebSocketCloseStatus.NormalClosure,
+ string.Empty,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (IsConnectionGone(ex))
+ {
+ // The peer is already gone, there is nobody left to send the close frame to.
+ _logger.LogDebug("WS {IP} error closing connection: {Message}", RemoteEndPoint, ex.Message);
+ }
}
}
+ private static bool IsConnectionGone(Exception ex)
+ => ex is WebSocketException or ObjectDisposedException or OperationCanceledException;
+
private async Task ProcessInternal(PipeReader reader)
{
ReadResult result = await reader.ReadAsync().ConfigureAwait(false);
diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs
index f634084034..b31cf0f1f5 100644
--- a/Emby.Server.Implementations/IO/FileRefresher.cs
+++ b/Emby.Server.Implementations/IO/FileRefresher.cs
@@ -109,6 +109,11 @@ namespace Emby.Server.Implementations.IO
lock (_timerLock)
{
+ if (_disposed)
+ {
+ return;
+ }
+
paths = _affectedPaths.ToList();
}
@@ -129,11 +134,12 @@ namespace Emby.Server.Implementations.IO
private void ProcessPathChanges(List<string> paths)
{
- IEnumerable<BaseItem> itemsToRefresh = paths
+ var itemsToRefresh = paths
.Distinct()
- .Select(GetAffectedBaseItem)
- .Where(item => item is not null)
- .DistinctBy(x => x!.Id)!; // Removed null values in the previous .Where()
+ .Select(TryGetAffectedBaseItem)
+ .OfType<BaseItem>()
+ .DistinctBy(x => x.Id)
+ .ToList();
foreach (var item in itemsToRefresh)
{
@@ -155,6 +161,19 @@ namespace Emby.Server.Implementations.IO
}
}
+ private BaseItem? TryGetAffectedBaseItem(string path)
+ {
+ try
+ {
+ return GetAffectedBaseItem(path);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error finding the item affected by changes to {Path}", path);
+ return null;
+ }
+ }
+
/// <summary>
/// Gets the affected base item.
/// </summary>
diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs
index 1bf0f8c76c..e51c863f86 100644
--- a/Emby.Server.Implementations/IO/LibraryMonitor.cs
+++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs
@@ -3,11 +3,13 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.Library;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -21,6 +23,7 @@ namespace Emby.Server.Implementations.IO
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem;
+ private readonly IDirectoryService _directoryService;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
/// <summary>
@@ -38,6 +41,12 @@ namespace Emby.Server.Implementations.IO
/// </summary>
private readonly ConcurrentDictionary<string, string> _tempIgnoredPaths = new(StringComparer.OrdinalIgnoreCase);
+ /// <summary>
+ /// Incremented by every <see cref="Stop"/> so watchers still being created on a background
+ /// task can tell that the sweep they should have been caught by has already run.
+ /// </summary>
+ private int _watcherGeneration;
+
private bool _disposed;
/// <summary>
@@ -47,6 +56,7 @@ namespace Emby.Server.Implementations.IO
/// <param name="libraryManager">The library manager.</param>
/// <param name="configurationManager">The configuration manager.</param>
/// <param name="fileSystem">The filesystem.</param>
+ /// <param name="directoryService">The directory service.</param>
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
public LibraryMonitor(
@@ -54,6 +64,7 @@ namespace Emby.Server.Implementations.IO
ILibraryManager libraryManager,
IServerConfigurationManager configurationManager,
IFileSystem fileSystem,
+ IDirectoryService directoryService,
IHostApplicationLifetime appLifetime,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
{
@@ -61,10 +72,11 @@ namespace Emby.Server.Implementations.IO
_logger = logger;
_configurationManager = configurationManager;
_fileSystem = fileSystem;
+ _directoryService = directoryService;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
appLifetime.ApplicationStarted.Register(Start);
- appLifetime.ApplicationStopping.Register(Stop);
+ appLifetime.ApplicationStopping.Register(Dispose);
}
/// <inheritdoc />
@@ -115,6 +127,11 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc />
public void Start()
{
+ if (_disposed)
+ {
+ return;
+ }
+
_libraryManager.ItemAdded += OnLibraryManagerItemAdded;
_libraryManager.ItemRemoved += OnLibraryManagerItemRemoved;
@@ -228,6 +245,8 @@ namespace Emby.Server.Implementations.IO
return;
}
+ var generation = Volatile.Read(ref _watcherGeneration);
+
// Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do them all in parallel
Task.Run(() =>
{
@@ -251,7 +270,11 @@ namespace Emby.Server.Implementations.IO
newWatcher.Changed += OnWatcherChanged;
newWatcher.Error += OnWatcherError;
- if (_fileSystemWatchers.TryAdd(path, newWatcher))
+ if (_disposed || Volatile.Read(ref _watcherGeneration) != generation)
+ {
+ DisposeWatcher(newWatcher, false);
+ }
+ else if (_fileSystemWatchers.TryAdd(path, newWatcher))
{
newWatcher.EnableRaisingEvents = true;
_logger.LogInformation("Watching directory {Path}", path);
@@ -352,6 +375,11 @@ namespace Emby.Server.Implementations.IO
{
ArgumentException.ThrowIfNullOrEmpty(path);
+ if (_disposed)
+ {
+ return;
+ }
+
if (IgnorePatterns.ShouldIgnore(path))
{
return;
@@ -363,6 +391,8 @@ namespace Emby.Server.Implementations.IO
return;
}
+ _directoryService.Invalidate(path);
+
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
foreach (var i in _tempIgnoredPaths.Keys)
{
@@ -445,6 +475,8 @@ namespace Emby.Server.Implementations.IO
/// </summary>
public void Stop()
{
+ Interlocked.Increment(ref _watcherGeneration);
+
_libraryManager.ItemAdded -= OnLibraryManagerItemAdded;
_libraryManager.ItemRemoved -= OnLibraryManagerItemRemoved;
@@ -489,8 +521,9 @@ namespace Emby.Server.Implementations.IO
return;
}
- Stop();
+ // Set before stopping so anything racing us stops handing out new work.
_disposed = true;
+ Stop();
}
}
}
diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
index ede9b27592..db743c8d31 100644
--- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs
+++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
@@ -489,11 +489,18 @@ namespace Emby.Server.Implementations.IO
ArgumentException.ThrowIfNullOrEmpty(parentPath);
ArgumentException.ThrowIfNullOrEmpty(path);
- return path.Contains(
- Path.TrimEndingDirectorySeparator(parentPath) + Path.DirectorySeparatorChar,
- _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
+ var parent = Path.TrimEndingDirectorySeparator(parentPath);
+
+ // The parent has to be an anchored prefix of the path, otherwise unrelated paths that merely
+ // contain the parent as a segment (e.g. /media and /data/media/tv) would be treated as related.
+ return path.Length > parent.Length
+ && path.StartsWith(parent, _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)
+ && (Path.EndsInDirectorySeparator(parent) || IsDirectorySeparator(path[parent.Length]));
}
+ private static bool IsDirectorySeparator(char c)
+ => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
+
/// <inheritdoc />
public virtual bool AreEqual(string path1, string path2)
{
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 3db8265f6e..caba304888 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -15,7 +16,6 @@ using Emby.Naming.Common;
using Emby.Naming.TV;
using Emby.Naming.Video;
using Emby.Server.Implementations.Library.Resolvers;
-using Emby.Server.Implementations.Library.Validators;
using Emby.Server.Implementations.Playlists;
using Emby.Server.Implementations.ScheduledTasks.Tasks;
using Emby.Server.Implementations.Sorting;
@@ -35,7 +35,6 @@ using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
@@ -75,7 +74,6 @@ namespace Emby.Server.Implementations.Library
private readonly Lazy<IProviderManager> _providerManagerFactory;
private readonly Lazy<IUserViewManager> _userViewManagerFactory;
private readonly IServerApplicationHost _appHost;
- private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem;
private readonly IItemRepository _itemRepository;
private readonly IItemPersistenceService _persistenceService;
@@ -88,6 +86,7 @@ namespace Emby.Server.Implementations.Library
private readonly ExtraResolver _extraResolver;
private readonly IPathManager _pathManager;
private readonly ILocalizationManager _localization;
+ private readonly IDirectoryService _directoryService;
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
private readonly IMediaStreamRepository _mediaStreamRepository;
@@ -122,7 +121,6 @@ namespace Emby.Server.Implementations.Library
/// <param name="fileSystem">The file system.</param>
/// <param name="providerManagerFactory">The provider manager.</param>
/// <param name="userViewManagerFactory">The user view manager.</param>
- /// <param name="mediaEncoder">The media encoder.</param>
/// <param name="itemRepository">The item repository.</param>
/// <param name="persistenceService">The item persistence service.</param>
/// <param name="nextUpService">The next up service.</param>
@@ -148,7 +146,6 @@ namespace Emby.Server.Implementations.Library
IFileSystem fileSystem,
Lazy<IProviderManager> providerManagerFactory,
Lazy<IUserViewManager> userViewManagerFactory,
- IMediaEncoder mediaEncoder,
IItemRepository itemRepository,
IItemPersistenceService persistenceService,
INextUpService nextUpService,
@@ -174,7 +171,6 @@ namespace Emby.Server.Implementations.Library
_fileSystem = fileSystem;
_providerManagerFactory = providerManagerFactory;
_userViewManagerFactory = userViewManagerFactory;
- _mediaEncoder = mediaEncoder;
_itemRepository = itemRepository;
_persistenceService = persistenceService;
_nextUpService = nextUpService;
@@ -189,6 +185,7 @@ namespace Emby.Server.Implementations.Library
_pathManager = pathManager;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
_localization = localization;
+ _directoryService = directoryService;
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
@@ -321,7 +318,7 @@ namespace Emby.Server.Implementations.Library
if (wizardChanged)
{
- _taskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>();
+ QueueLibraryScan();
}
}
@@ -881,7 +878,18 @@ namespace Emby.Server.Implementations.Library
wrongTypeItem.GetType().Name,
expectedVideoType.Name,
path);
- DeleteItem(wrongTypeItem, new DeleteOptions { DeleteFileLocation = false });
+
+ // A full DeleteItem would save the primary version, which resolves its
+ // alternates again and re-enters here before this row is gone.
+ DeleteItemsUnsafeFast([wrongTypeItem]);
+
+ // The fast path skips the parent bookkeeping, and the stale item is listed
+ // under its ParentId, so that folder's cached listing has to be dropped.
+ if (wrongTypeItem.GetParent() is Folder staleParent)
+ {
+ staleParent.Children = null;
+ staleParent.UserData = null;
+ }
}
}
@@ -1210,6 +1218,12 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc />
+ public Guid GetPersonId(string name)
+ {
+ return GetItemByNameId<Person>(Person.GetPath(name));
+ }
+
+ /// <inheritdoc />
public Person? GetPerson(string name)
{
var path = Person.GetPath(name);
@@ -1222,6 +1236,33 @@ namespace Emby.Server.Implementations.Library
return null;
}
+ /// <inheritdoc />
+ public Person GetOrCreatePerson(string name)
+ {
+ var existing = GetPerson(name);
+ if (existing is not null)
+ {
+ return existing;
+ }
+
+ var path = Person.GetPath(name);
+ var info = Directory.CreateDirectory(path);
+ var item = new Person
+ {
+ Name = name,
+ Id = GetItemByNameId<Person>(path),
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc,
+ Path = path
+ };
+
+ item.PresentationUniqueKey = item.CreatePresentationUniqueKey();
+
+ CreateItem(item, null);
+
+ return item;
+ }
+
/// <summary>
/// Gets the studio.
/// </summary>
@@ -1354,15 +1395,6 @@ namespace Emby.Server.Implementations.Library
return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId);
}
- /// <inheritdoc />
- public Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken)
- {
- // Ensure the location is available.
- Directory.CreateDirectory(_configurationManager.ApplicationPaths.PeoplePath);
-
- return new PeopleValidator(this, _logger, _fileSystem).ValidatePeople(cancellationToken, progress);
- }
-
/// <summary>
/// Reloads the root media folder.
/// </summary>
@@ -1489,6 +1521,10 @@ namespace Emby.Server.Implementations.Library
var numComplete = 0;
var numTasks = tasks.Count;
+ _logger.LogInformation("Running {TaskCount} post-scan task(s)", numTasks);
+
+ var phaseStart = Stopwatch.GetTimestamp();
+
foreach (var task in tasks)
{
// Prevent access to modified closure
@@ -1506,20 +1542,45 @@ namespace Emby.Server.Implementations.Library
progress.Report(innerPercent);
});
- _logger.LogDebug("Running post-scan task {0}", task.GetType().Name);
+ var taskName = task.GetType().Name;
+ var taskStart = Stopwatch.GetTimestamp();
+
+ _logger.LogInformation(
+ "Running post-scan task {TaskNumber}/{TaskCount}: {TaskName}",
+ currentNumComplete + 1,
+ numTasks,
+ taskName);
try
{
await task.Run(innerProgress, cancellationToken).ConfigureAwait(false);
+
+ var elapsed = Stopwatch.GetElapsedTime(taskStart);
+ _logger.LogInformation(
+ "Post-scan task {TaskName} completed after {Minutes} minute(s) and {Seconds} seconds",
+ taskName,
+ Math.Truncate(elapsed.TotalMinutes),
+ elapsed.Seconds);
}
catch (OperationCanceledException)
{
- _logger.LogInformation("Post-scan task cancelled: {0}", task.GetType().Name);
+ var elapsed = Stopwatch.GetElapsedTime(taskStart);
+ _logger.LogInformation(
+ "Post-scan task {TaskName} cancelled after {Minutes} minute(s) and {Seconds} seconds",
+ taskName,
+ Math.Truncate(elapsed.TotalMinutes),
+ elapsed.Seconds);
throw;
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error running post-scan task");
+ var elapsed = Stopwatch.GetElapsedTime(taskStart);
+ _logger.LogError(
+ ex,
+ "Post-scan task {TaskName} failed after {Minutes} minute(s) and {Seconds} seconds",
+ taskName,
+ Math.Truncate(elapsed.TotalMinutes),
+ elapsed.Seconds);
}
numComplete++;
@@ -1528,6 +1589,12 @@ namespace Emby.Server.Implementations.Library
progress.Report(percent * 100);
}
+ var phaseElapsed = Stopwatch.GetElapsedTime(phaseStart);
+ _logger.LogInformation(
+ "All post-scan tasks completed after {Minutes} minute(s) and {Seconds} seconds",
+ Math.Truncate(phaseElapsed.TotalMinutes),
+ phaseElapsed.Seconds);
+
_persistenceService.UpdateInheritedValues();
progress.Report(100);
@@ -1745,6 +1812,18 @@ namespace Emby.Server.Implementations.Library
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
}
+ /// <inheritdoc/>
+ public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user)
+ {
+ var query = new InternalItemsQuery(user);
+ if (user is not null)
+ {
+ AddUserToQuery(query, user);
+ }
+
+ return _countService.GetItemCountsForNameItems(kind, ids, relatedItemKinds, query);
+ }
+
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
{
return _countService.GetChildCountBatch(parentIds, user);
@@ -3720,6 +3799,10 @@ namespace Emby.Server.Implementations.Library
AddMediaPathInternal(name, path, false);
}
}
+
+ // The libraries root was listed before this folder existed, so drop that listing:
+ // anything still reading it resolves the library set without the new folder.
+ _directoryService.Invalidate(virtualFolderPath);
}
finally
{
@@ -3727,7 +3810,7 @@ namespace Emby.Server.Implementations.Library
if (refreshLibrary)
{
- StartScanInBackground();
+ _ = StartScanInBackground();
}
else
{
@@ -3746,27 +3829,14 @@ namespace Emby.Server.Implementations.Library
var itemUpdateType = ItemUpdateType.MetadataDownload;
var saveEntity = false;
- var createEntity = false;
var personEntity = GetPerson(person.Name);
if (personEntity is null)
{
try
{
- var path = Person.GetPath(person.Name);
- var info = Directory.CreateDirectory(path);
- personEntity = new Person()
- {
- Name = person.Name,
- Id = GetItemByNameId<Person>(path),
- DateCreated = info.CreationTimeUtc,
- DateModified = info.LastWriteTimeUtc,
- Path = path
- };
-
- personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey();
+ personEntity = GetOrCreatePerson(person.Name);
saveEntity = true;
- createEntity = true;
}
catch (Exception ex)
{
@@ -3800,11 +3870,6 @@ namespace Emby.Server.Implementations.Library
if (saveEntity)
{
- if (createEntity)
- {
- CreateItems([personEntity], null, CancellationToken.None);
- }
-
await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
personEntity.DateLastSaved = DateTime.UtcNow;
@@ -3813,13 +3878,16 @@ namespace Emby.Server.Implementations.Library
}
}
- private void StartScanInBackground()
+ internal Task StartScanInBackground()
{
- Task.Run(() =>
+ // An active scan already handles library structure changes, so this request can be dropped.
+ if (IsScanRunning)
{
- // No need to start if scanning the library because it will handle it
- ValidateMediaLibrary(new Progress<double>(), CancellationToken.None);
- });
+ return Task.CompletedTask;
+ }
+
+ // Queue instead of restarting so a scan that starts after the check is allowed to finish.
+ return Task.Run(QueueLibraryScan);
}
public void AddMediaPath(string virtualFolderName, MediaPathInfo mediaPath)
@@ -3920,6 +3988,7 @@ namespace Emby.Server.Implementations.Library
try
{
Directory.Delete(path, true);
+ _directoryService.Invalidate(path);
}
finally
{
@@ -3929,7 +3998,7 @@ namespace Emby.Server.Implementations.Library
{
await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
- StartScanInBackground();
+ _ = StartScanInBackground();
}
else
{
@@ -3989,6 +4058,7 @@ namespace Emby.Server.Implementations.Library
if (!string.IsNullOrEmpty(shortcut))
{
_fileSystem.DeleteFile(shortcut);
+ _directoryService.Invalidate(shortcut);
}
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
@@ -4032,6 +4102,7 @@ namespace Emby.Server.Implementations.Library
}
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
+ _directoryService.Invalidate(lnk);
RemoveContentTypeOverrides(path);
}
@@ -4071,6 +4142,18 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc />
+ public IReadOnlyList<string> GetTagNames(InternalItemsQuery query)
+ {
+ if (query.User is not null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ SetTopParentOrAncestorIds(query);
+ return _itemRepository.GetTagNames(query);
+ }
+
+ /// <inheritdoc />
public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType)
{
return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType);
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 97e00177b6..e9bba05839 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -384,7 +384,13 @@ namespace Emby.Server.Implementations.Library
{
ArgumentNullException.ThrowIfNull(item);
- var hasMediaSources = (IHasMediaSources)item;
+ // Clients can ask for the sources of an item that has none (a container queued by mistake).
+ if (item is not IHasMediaSources hasMediaSources)
+ {
+ throw new ArgumentException(
+ string.Format(CultureInfo.InvariantCulture, "{0} {1} has no media sources and cannot be played.", item.GetType().Name, item.Id),
+ nameof(item));
+ }
var sources = hasMediaSources.GetMediaSources(enablePathSubstitution);
@@ -494,7 +500,12 @@ namespace Emby.Server.Implementations.Library
{
var index = userData.SubtitleStreamIndex.Value;
// Make sure the saved index is still valid
- if (index == -1 || source.MediaStreams.Any(i => i.Type == MediaStreamType.Subtitle && i.Index == index))
+ var savedStream = source.MediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Subtitle && i.Index == index);
+ // "Only forced" rules out full tracks entirely, so a remembered one must not resurrect them.
+ // The client reports whatever is playing, so an index remembered under another mode sticks forever otherwise.
+ if (index == -1
+ || (savedStream is not null
+ && (user.SubtitleMode != SubtitlePlaybackMode.OnlyForced || savedStream.IsForced)))
{
source.DefaultSubtitleStreamIndex = index;
return;
diff --git a/Emby.Server.Implementations/Library/Search/SearchManager.cs b/Emby.Server.Implementations/Library/Search/SearchManager.cs
index 306a8673d5..a8ee416b31 100644
--- a/Emby.Server.Implementations/Library/Search/SearchManager.cs
+++ b/Emby.Server.Implementations/Library/Search/SearchManager.cs
@@ -143,11 +143,19 @@ public class SearchManager : ISearchManager
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
- var allowedIds = await baseQuery
- .Select(e => e.Id)
- .ToHashSetAsync(cancellationToken)
+ var allowed = await baseQuery
+ .Select(e => new { e.Id, e.PrimaryVersionId })
+ .ToListAsync(cancellationToken)
.ConfigureAwait(false);
+ var allowedIds = allowed.Select(e => e.Id).ToHashSet();
+
+ // A provider can return both an alternate version and the primary it belongs to, and the
+ // two are one item to the user.
+ allowedIds.ExceptWith(allowed
+ .Where(e => e.PrimaryVersionId.HasValue && allowedIds.Contains(e.PrimaryVersionId.Value))
+ .Select(e => e.Id));
+
if (allowedIds.Count == candidates.Count)
{
return candidates;
diff --git a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
index c4d3b249d5..2cbfb6a4fa 100644
--- a/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
+++ b/Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
@@ -115,6 +115,7 @@ public class SqlSearchProvider : IInternalSearchProvider
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
+ dbQuery = ExcludeVersionsOfMatchedPrimaries(dbQuery);
// 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
@@ -193,6 +194,12 @@ public class SqlSearchProvider : IInternalSearchProvider
return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
}
+ private static IQueryable<BaseItemEntity> ExcludeVersionsOfMatchedPrimaries(IQueryable<BaseItemEntity> query)
+ {
+ var matched = query;
+ return query.Where(e => e.PrimaryVersionId == null || !matched.Any(p => p.Id == e.PrimaryVersionId));
+ }
+
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
JellyfinDbContext dbContext,
IQueryable<BaseItemEntity> query,
diff --git a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
index 57d1f7c770..cc8f0fd24e 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/MovieSimilarItemsProvider.cs
@@ -1,3 +1,5 @@
+#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees.
+
using System;
using System.Collections.Generic;
using System.Linq;
@@ -172,7 +174,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
var allCandidateIdsList = allCandidateIds.ToList();
var accessibleItems = await baseQuery
.WhereOneOrMany(allCandidateIdsList, e => e.Id)
- .Select(e => new { e.Id, e.PresentationUniqueKey })
+ .Select(e => new { e.Id, e.PresentationUniqueKey, e.PrimaryVersionId })
.ToListAsync(cancellationToken).ConfigureAwait(false);
// Phase 3: Pick top IDs per source, dedup by PresentationUniqueKey
@@ -189,6 +191,9 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
var orderedIds = accessibleItems
.Where(x => scores.ContainsKey(x.Id))
.OrderByDescending(x => scores.GetValueOrDefault(x.Id))
+ // Two versions of one movie score the same, so name the primary as the
+ // representative of the group rather than whichever came back first.
+ .ThenBy(x => x.PrimaryVersionId.HasValue)
.DistinctBy(x => x.PresentationUniqueKey)
.Take(limit)
.Select(x => x.Id)
@@ -245,6 +250,11 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
result[id] = [];
}
+ var hiddenVersionIds = context.BaseItems.AsNoTracking()
+ .Where(e => e.PrimaryVersionId != null
+ && context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId))
+ .Select(e => e.Id);
+
foreach (var (valueType, weight) in _itemValueDimensions)
{
var sourceRows = await context.ItemValuesMap.AsNoTracking()
@@ -260,7 +270,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
}
var candidateRows = await context.ItemValuesMap.AsNoTracking()
- .Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
+ .Where(m => !hiddenVersionIds.Contains(m.ItemId) && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
.ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -276,6 +286,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
if (personSourceRows.Count > 0)
{
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
+ .Where(m => !hiddenVersionIds.Contains(m.ItemId))
.Where(m => context.PeopleBaseItemMap
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
.Select(s => s.PeopleId)
diff --git a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
index fd5f292ebe..a18a17b593 100644
--- a/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
+++ b/Emby.Server.Implementations/Library/SimilarItems/SimilarItemsManager.cs
@@ -651,7 +651,13 @@ public class SimilarItemsManager : ISimilarItemsManager
try
{
- var stream = File.OpenRead(cachePath);
+ var stream = new FileStream(
+ cachePath,
+ FileMode.Open,
+ FileAccess.Read,
+ FileShare.ReadWrite | FileShare.Delete,
+ IODefaults.FileStreamBufferSize,
+ FileOptions.Asynchronous | FileOptions.SequentialScan);
await using (stream.ConfigureAwait(false))
{
var cache = await JsonSerializer.DeserializeAsync<SimilarItemsCache>(stream, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
@@ -675,6 +681,7 @@ public class SimilarItemsManager : ISimilarItemsManager
private async Task SaveSimilarItemsCacheAsync(string cachePath, List<SimilarItemReference> references, TimeSpan cacheDuration, CancellationToken cancellationToken)
{
+ string? tempPath = null;
try
{
var directory = Path.GetDirectoryName(cachePath);
@@ -689,16 +696,34 @@ public class SimilarItemsManager : ISimilarItemsManager
ExpiresAt = DateTime.UtcNow.Add(cacheDuration)
};
- var stream = File.Create(cachePath);
+ tempPath = cachePath + "." + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + ".tmp";
+ var stream = File.Create(tempPath);
await using (stream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(stream, cache, JsonDefaults.Options, cancellationToken).ConfigureAwait(false);
}
+
+ File.Move(tempPath, cachePath, true);
+ tempPath = null;
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Failed to save similar items cache to {CachePath}", cachePath);
}
+ finally
+ {
+ if (tempPath is not null)
+ {
+ try
+ {
+ File.Delete(tempPath);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogDebug(ex, "Failed to delete temporary similar items cache file {TempPath}", tempPath);
+ }
+ }
+ }
}
private sealed class SimilarItemsCache
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
index 47b3891901..cfb2dd53d3 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -60,17 +60,10 @@ namespace Emby.Server.Implementations.Library
var folderViewType = collectionFolder?.CollectionType;
// Playlist and BoxSet libraries require special handling because the folder only references linked items
- if (folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
+ if ((folderViewType == CollectionType.playlists || folderViewType == CollectionType.boxsets)
+ && !HasVisibleChild(folder, user))
{
- var items = folder.GetItemList(new InternalItemsQuery(user)
- {
- ParentId = folder.ParentId
- });
-
- if (!items.Any(item => item.IsVisible(user)))
- {
- continue;
- }
+ continue;
}
if (UserView.IsUserSpecific(folder))
@@ -127,7 +120,7 @@ namespace Emby.Server.Implementations.Library
list.AddRange(channels);
- if (_liveTvManager.GetEnabledUsers().Select(i => i.Id).Contains(user.Id))
+ if (_liveTvManager.IsEnabledForUser(user))
{
list.Add(_liveTvManager.GetInternalLiveTvFolder(CancellationToken.None));
}
@@ -159,6 +152,32 @@ namespace Emby.Server.Implementations.Library
.ToArray();
}
+ private bool HasVisibleChild(Folder folder, User user)
+ {
+ // Folder.Children answers this too, but a collection folder delegates it to its physical
+ // folders, which resolve and then hold on to every child with every field.
+ var parentIds = folder is CollectionFolder collectionFolder && collectionFolder.PhysicalFolderIds.Length > 0
+ ? collectionFolder.PhysicalFolderIds
+ : [folder.Id];
+
+ foreach (var parentId in parentIds)
+ {
+ var items = _libraryManager.GetItemList(new InternalItemsQuery(user)
+ {
+ ParentId = parentId,
+ GroupByPresentationUniqueKey = false,
+ DtoOptions = DtoOptions.StoredColumnsOnly
+ });
+
+ if (items.Any(item => item.IsVisible(user)))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
public UserView GetUserSubViewWithName(string name, Guid parentId, CollectionType? type, string sortName)
{
var uniqueId = parentId + "subview" + type;
diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
index fa7112eb90..690466be70 100644
--- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
@@ -61,6 +62,9 @@ public class ArtistsValidator
var count = names.Count;
var refreshed = 0;
+ var liveIds = new HashSet<Guid>();
+ var unresolved = 0;
+
foreach (var name in names)
{
try
@@ -73,13 +77,20 @@ public class ArtistsValidator
// Fall back to GetArtist if not found (creates new item if needed)
item ??= _libraryManager.GetArtist(name);
- var isNew = !existingArtistIds.Contains(item.Id);
- var neverRefreshed = item.DateLastRefreshed == default;
- if (isNew || neverRefreshed)
+ // A name with no item is nothing to refresh, and nothing to keep alive either.
+ if (item is not null)
{
- await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
- refreshed++;
+ liveIds.Add(item.Id);
+
+ var isNew = !existingArtistIds.Contains(item.Id);
+ var neverRefreshed = item.DateLastRefreshed == default;
+
+ if (isNew || neverRefreshed)
+ {
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ refreshed++;
+ }
}
}
catch (OperationCanceledException)
@@ -88,6 +99,7 @@ public class ArtistsValidator
}
catch (Exception ex)
{
+ unresolved++;
_logger.LogError(ex, "Error refreshing {ArtistName}", name);
}
@@ -101,13 +113,26 @@ public class ArtistsValidator
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count);
+ // Every name that threw is a name whose artist is missing from the live set, and deleting against
+ // a live set with holes in it deletes artists the library still refers to. Leave the sweep to a
+ // run that got a clean read of them.
+ if (unresolved > 0)
+ {
+ _logger.LogWarning(
+ "Not removing dead artists: {Count} of {TotalCount} names could not be resolved this run",
+ unresolved,
+ count);
+
+ progress.Report(100);
+ return;
+ }
+
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.MusicArtist],
- IsDeadArtist = true,
IsLocked = false
- }).Cast<MusicArtist>()
- .Where(item => item.IsAccessedByName)
+ }).OfType<MusicArtist>()
+ .Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id))
.ToList();
foreach (var item in deadEntities)
diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
index 078a0b921d..7d53f40ce7 100644
--- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
@@ -1,12 +1,12 @@
using System;
+using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library.Validators;
@@ -17,112 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators;
public class PeopleValidator
{
/// <summary>
- /// The _library manager.
+ /// The library manager.
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
- /// The _logger.
+ /// The logger.
/// </summary>
- private readonly ILogger _logger;
-
- private readonly IFileSystem _fileSystem;
+ private readonly ILogger<PeopleValidator> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="PeopleValidator" /> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="logger">The logger.</param>
- /// <param name="fileSystem">The file system.</param>
- public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem)
+ public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger)
{
_libraryManager = libraryManager;
_logger = logger;
- _fileSystem = fileSystem;
}
/// <summary>
/// Validates the people.
/// </summary>
- /// <param name="cancellationToken">The cancellation token.</param>
/// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{
// Before the refresh below walks them: a credit no item maps to any more stands for nothing,
// and while it is there the person it names cannot reach the dead-person sweep either.
var numOrphaned = _libraryManager.DeleteOrphanedCredits();
if (numOrphaned > 0)
{
- _logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned);
+ _logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned);
}
- var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
-
- var numComplete = 0;
-
- var numPeople = people.Count;
+ var names = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
+ var existingPersonIds = _libraryManager.GetItemIds(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Person]
+ }).ToHashSet();
- IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
+ var (newNames, deadIds) = PartitionCreditsByPersonId(names, _libraryManager.GetPersonId, existingPersonIds);
- _logger.LogDebug("Will refresh {Amount} people", numPeople);
+ var numComplete = 0;
+ var count = names.Count;
+ var refreshed = 0;
- foreach (var person in people)
+ foreach (var name in names)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
- var item = _libraryManager.GetPerson(person);
- if (item is null)
- {
- _logger.LogWarning("Failed to get person: {Name}", person);
- continue;
- }
+ var item = _libraryManager.GetOrCreatePerson(name);
+ var isNew = !existingPersonIds.Contains(item.Id);
+ var neverRefreshed = item.DateLastRefreshed == default;
- var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+ if (isNew || neverRefreshed)
{
- ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
- MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
- };
-
- await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ refreshed++;
+ }
}
catch (OperationCanceledException)
{
+ // Don't clutter the log
throw;
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error validating IBN entry {Person}", person);
+ _logger.LogError(ex, "Error refreshing {PersonName}", name);
}
- // Update progress
numComplete++;
double percent = numComplete;
- percent /= numPeople;
+ percent /= count;
+ percent *= 100;
- subProgress.Report(100 * percent);
+ progress.Report(percent);
}
- var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
- {
- IncludeItemTypes = [BaseItemKind.Person],
- IsDeadPerson = true,
- IsLocked = false
- });
+ _logger.LogInformation(
+ "Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet",
+ refreshed,
+ count,
+ newNames.Count);
- subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
+ // A person somebody locked is theirs, not ours, however little the library still credits them.
+ var deadEntities = deadIds
+ .Select(_libraryManager.GetItemById)
+ .OfType<Person>()
+ .Where(item => !item.IsLocked)
+ .ToList();
- var i = 0;
- foreach (var item in deadEntities.Chunk(500))
+ foreach (var item in deadEntities)
{
- _libraryManager.DeleteItemsUnsafeFast(item, true);
- subProgress.Report(100f / deadEntities.Count * (i++ * 100));
+ _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
}
+ _libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
+
progress.Report(100);
+ }
+
+ /// <summary>
+ /// Splits the person items into the ones a credit still calls for and the ones nothing does.
+ /// </summary>
+ /// <param name="creditNames">Every name credited on an item, from the people table.</param>
+ /// <param name="getPersonId">Maps a credit name to the id its person item has.</param>
+ /// <param name="existingPersonIds">The ids of the person items that exist.</param>
+ /// <returns>The credits needing an item, and the ids of the items nothing credits.</returns>
+ internal static (List<string> NewNames, List<Guid> DeadIds) PartitionCreditsByPersonId(
+ IReadOnlyList<string> creditNames,
+ Func<string, Guid> getPersonId,
+ IReadOnlySet<Guid> existingPersonIds)
+ {
+ ArgumentNullException.ThrowIfNull(creditNames);
+ ArgumentNullException.ThrowIfNull(getPersonId);
+ ArgumentNullException.ThrowIfNull(existingPersonIds);
+
+ var newNames = new List<string>();
+ var liveIds = new HashSet<Guid>();
+
+ foreach (var name in creditNames)
+ {
+ var personId = getPersonId(name);
+
+ // Distinct credit names can normalize onto one id; only the first of them needs an item.
+ if (liveIds.Add(personId) && !existingPersonIds.Contains(personId))
+ {
+ newNames.Add(name);
+ }
+ }
+
+ var deadIds = existingPersonIds.Where(id => !liveIds.Contains(id)).ToList();
- _logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned);
+ return (newNames, deadIds);
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json
index 49ebc45f06..c5b1213096 100644
--- a/Emby.Server.Implementations/Localization/Core/be.json
+++ b/Emby.Server.Implementations/Localization/Core/be.json
@@ -112,5 +112,11 @@
"NameExtraInterview": "Інтэрв'ю",
"NameExtraNumbered": "{0} {1}",
"NameExtraScene": "Сцэна",
- "NameExtraTrailer": "Трэйлер"
+ "NameExtraTrailer": "Трэйлер",
+ "NameExtraBehindTheScenes": "За кулісамі",
+ "NameExtraClip": "Кліп",
+ "NameExtraFeaturette": "Кароткаметражка",
+ "NameExtraSample": "Прыклад",
+ "NameExtraShort": "Кароткаметражка",
+ "NameExtraThemeSong": "Тэматычная песня"
}
diff --git a/Emby.Server.Implementations/Localization/Core/bs.json b/Emby.Server.Implementations/Localization/Core/bs.json
index aa7fe4eb24..5686807d9a 100644
--- a/Emby.Server.Implementations/Localization/Core/bs.json
+++ b/Emby.Server.Implementations/Localization/Core/bs.json
@@ -106,5 +106,17 @@
"TaskMoveTrickplayImages": "Migracija lokacije slike Trickplay",
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.",
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
- "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana."
+ "CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana.",
+ "NameExtraBehindTheScenes": "Iza kulisa",
+ "NameExtraClip": "Isječak",
+ "NameExtraDeletedScene": "Izbrišana scena",
+ "NameExtraFeaturette": "Kratki prilog",
+ "NameExtraInterview": "Intervju",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Uzorak",
+ "NameExtraScene": "Scena",
+ "NameExtraShort": "Kratko",
+ "NameExtraThemeSong": "Tema",
+ "NameExtraThemeVideo": "Tematski video",
+ "NameExtraTrailer": "Najava"
}
diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json
index 033002d2b2..cfc16d553c 100644
--- a/Emby.Server.Implementations/Localization/Core/cs.json
+++ b/Emby.Server.Implementations/Localization/Core/cs.json
@@ -1,6 +1,6 @@
{
"AppDeviceValues": "Aplikace: {0}, Zařízení: {1}",
- "Artists": "Umělci",
+ "Artists": "Interpreti",
"AuthenticationSucceededWithUserName": "{0} úspěšně ověřen",
"Books": "Knihy",
"ChapterNameValue": "Kapitola {0}",
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index 6e5532ead9..4c7d16a9a7 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -10,7 +10,7 @@
"Folders": "Mapper",
"Genres": "Genrer",
"HeaderContinueWatching": "Fortsæt med at se",
- "HeaderFavoriteEpisodes": "Yndlingsafsnit",
+ "HeaderFavoriteEpisodes": "Favoritepisoder",
"HeaderFavoriteShows": "Yndlingsserier",
"HeaderLiveTV": "Live-TV",
"HeaderNextUp": "Næste",
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index c0ad2c165a..ee03c471ea 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
- "Original": "Πρωτότυπο"
+ "Original": "Πρωτότυπο",
+ "NameExtraBehindTheScenes": "Πίσω από τις Σκηνές",
+ "NameExtraDeletedScene": "Διεγραμμένη Σκηνή",
+ "NameExtraFeaturette": "Πρόσθετα βίντεο",
+ "NameExtraInterview": "Συνέντευξη",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Δείγμα",
+ "NameExtraScene": "Σκηνή",
+ "NameExtraShort": "Βίντεο μικρού μήκους",
+ "NameExtraThemeSong": "Θεματικό Τραγούδι",
+ "NameExtraThemeVideo": "Θεματικό Βίντεο",
+ "NameExtraTrailer": "τρέιλερ ταινίας",
+ "NameExtraUnknown": "Πρόσθετα",
+ "NameExtraClip": "Απόσπασμα"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
index 36a248a1d1..9a453120dd 100644
--- a/Emby.Server.Implementations/Localization/Core/fi.json
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -113,5 +113,12 @@
"NameExtraClip": "Klippi",
"NameExtraDeletedScene": "Poistettu Kohtaus",
"NameExtraFeaturette": "Lyhytelokuva",
- "NameExtraInterview": "Haastattelu"
+ "NameExtraInterview": "Haastattelu",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Näyte",
+ "NameExtraScene": "Kohtaus",
+ "NameExtraShort": "Lyhytfilmi",
+ "NameExtraThemeSong": "Tunnusmusiikki",
+ "NameExtraThemeVideo": "Tunnusvideo",
+ "NameExtraTrailer": "Traileri"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json
index 1a1c89da35..acb78290f3 100644
--- a/Emby.Server.Implementations/Localization/Core/fo.json
+++ b/Emby.Server.Implementations/Localization/Core/fo.json
@@ -7,8 +7,8 @@
"AppDeviceValues": "App: {0}, Eind: {1}",
"Books": "Bøkur",
"ChapterNameValue": "Kapittul {0}",
- "Favorites": "Yndis",
- "Folders": "Mappur",
+ "Favorites": "Yndislisti",
+ "Folders": "Skjáttur",
"Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar",
@@ -25,7 +25,7 @@
"NewVersionIsAvailable": "Ein nýggj útgáva av Jellyfin ambætaranum er tøk.",
"NotificationOptionNewLibraryContent": "Nýtt tilfar innlagt",
"NotificationOptionPluginInstalled": "Ískoytisforrit innlagt",
- "NotificationOptionPluginUninstalled": "Ískoytisforrit strikað",
+ "NotificationOptionPluginUninstalled": "Ískoytisforrit er strikað",
"NotificationOptionPluginUpdateInstalled": "Ískoytisforrit dagført",
"NotificationOptionUserLockedOut": "Brúkari útihýstur",
"Photos": "Ljósmyndir",
@@ -72,7 +72,7 @@
"UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
"HomeVideos": "Heimaupptøkur",
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
- "UserOfflineFromDevice": "{0} breyt av á {1}",
+ "UserOfflineFromDevice": "{0} breyt av frá {1}",
"UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt",
"TasksChannelsCategory": "Alnetsrásir",
"TaskCleanActivityLog": "Reinsa virksemisskrá",
@@ -83,7 +83,7 @@
"TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir",
"TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir",
"CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.",
- "CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur",
+ "CleanupUserDataTask": "Reinsa brúkaradátur",
"TaskRefreshPeople": "Dagfør persónsupplýsingar",
"TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.",
"TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.",
@@ -96,15 +96,15 @@
"TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.",
"TaskRefreshChapterImages": "Kapitlamyndaúttøkur",
"NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað",
- "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað",
+ "NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl er steðgað",
"NotificationOptionAudioPlayback": "Ljóðspæl byrjað",
- "NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað",
+ "NotificationOptionAudioPlaybackStopped": "Ljóðspæl er steðgað",
"TaskExtractMediaSegments": "Leita eftir margmiðlabrotum",
"TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.",
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
"NameExtraShort": "Stuttfilmur",
"NameExtraThemeSong": "Eyðkennislag",
- "NameExtraTrailer": "Forfilmur",
+ "NameExtraTrailer": "Brellbiti",
"NameExtraInterview": "Samrøða",
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
"NameExtraClip": "Klipp",
@@ -112,14 +112,14 @@
"NameExtraFeaturette": "Stuttur heimildarfilmur",
"TaskAudioNormalization": "Ljóðjavnan",
"TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.",
- "NameExtraSample": "Kut",
+ "NameExtraSample": "Sýnislutur",
"TaskRefreshTrickplayImages": "Framleið Trickplay-myndir",
"TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.",
- "TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað",
+ "TaskMoveTrickplayImages": "Flyt Trickplay-myndir",
"TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.",
"NameExtraThemeVideo": "Eyðkenniskykmynd",
"NameExtraDeletedScene": "Úrtikin mynd",
- "NameExtraScene": "Mynd (scena)",
+ "NameExtraScene": "Mynd",
"NameExtraUnknown": "Eykatilfar",
"Original": "Upprunalig(t/ur)"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json
index 1ee606cc64..30e11d15f0 100644
--- a/Emby.Server.Implementations/Localization/Core/ga.json
+++ b/Emby.Server.Implementations/Localization/Core/ga.json
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora",
"CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.",
"Original": "Bunaidh",
- "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}"
+ "LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}",
+ "NameExtraBehindTheScenes": "Taobh thiar de na Radhairc",
+ "NameExtraClip": "Gearrthóg",
+ "NameExtraDeletedScene": "Radharc Scriosta",
+ "NameExtraFeaturette": "Mionghné",
+ "NameExtraInterview": "Agallamh",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Sampla",
+ "NameExtraScene": "Radharc",
+ "NameExtraShort": "Gearr",
+ "NameExtraThemeSong": "Amhrán Téama",
+ "NameExtraThemeVideo": "Físeán Téama",
+ "NameExtraTrailer": "Leantóir"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index 0ca7c6aa08..04877b18a7 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -112,8 +112,14 @@
"NameExtraBehindTheScenes": "מאחורי הקלעים",
"NameExtraClip": "קליפ",
"NameExtraDeletedScene": "סצנה שנמחקה",
- "NameExtraFeaturette": "סרט קצר",
+ "NameExtraFeaturette": "סרט עלילה קצר",
"NameExtraInterview": "ריאיון",
"NameExtraSample": "דגימה",
- "NameExtraScene": "סצנה"
+ "NameExtraScene": "סצנה",
+ "NameExtraThemeSong": "שיר נושא",
+ "NameExtraThemeVideo": "סרטון נושא",
+ "NameExtraTrailer": "קדימון",
+ "NameExtraUnknown": "נוסף",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraShort": "סרט קצר"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index 442c26b30b..c7e2b5f5e9 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -108,5 +108,18 @@
"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",
- "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
+ "LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo",
+ "NameExtraBehindTheScenes": "Iza kulisa",
+ "NameExtraClip": "Klip",
+ "NameExtraDeletedScene": "Obrisana Scena",
+ "NameExtraFeaturette": "Promotivni video",
+ "NameExtraInterview": "Intervju",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Uzorak",
+ "NameExtraScene": "Scena",
+ "NameExtraShort": "Kratki film",
+ "NameExtraThemeSong": "Glavna Pjesma",
+ "NameExtraThemeVideo": "Tema videa",
+ "NameExtraTrailer": "Trailer",
+ "NameExtraUnknown": "Dodatno"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index 78b7ec744b..49757d66e7 100644
--- a/Emby.Server.Implementations/Localization/Core/ja.json
+++ b/Emby.Server.Implementations/Localization/Core/ja.json
@@ -2,7 +2,7 @@
"AppDeviceValues": "アプリ: {0}, デバイス: {1}",
"Artists": "アーティスト",
"AuthenticationSucceededWithUserName": "{0} 認証に成功しました",
- "Books": "ブック",
+ "Books": "書籍",
"ChapterNameValue": "チャプター {0}",
"Collections": "コレクション",
"FailedLoginAttemptWithUserName": "{0} からのログインに失敗しました",
diff --git a/Emby.Server.Implementations/Localization/Core/lb.json b/Emby.Server.Implementations/Localization/Core/lb.json
index 917f26a49c..1e64a130eb 100644
--- a/Emby.Server.Implementations/Localization/Core/lb.json
+++ b/Emby.Server.Implementations/Localization/Core/lb.json
@@ -83,7 +83,7 @@
"NewVersionIsAvailable": "Nei Versioun fir Jellyfin Server ass verfügbar.",
"PluginInstalledWithName": "{0} installéiert",
"TaskMoveTrickplayImagesDescription": "Verschëfft existent Trickplay-Dateien no de Bibliothéik-Astellungen.",
- "AppDeviceValues": "App: {0}, Geräter: {1}",
+ "AppDeviceValues": "App: {0}, Apparater: {1}",
"FailedLoginAttemptWithUserName": "Net Gelongen Umeldung {0}",
"HeaderLiveTV": "LiveTV",
"NotificationOptionServerRestartRequired": "Server Restart Erfuerderlech",
@@ -108,5 +108,17 @@
"LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}",
"Original": "Original",
"CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten",
- "CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn."
+ "CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn.",
+ "NameExtraBehindTheScenes": "Hannert de Kulissen",
+ "NameExtraClip": "Clip",
+ "NameExtraDeletedScene": "Geläschte Scène",
+ "NameExtraFeaturette": "Featurette",
+ "NameExtraInterview": "Interview",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Beispill",
+ "NameExtraScene": "Scène",
+ "NameExtraShort": "Kuerzfilm",
+ "NameExtraThemeSong": "Theme-Lidd",
+ "NameExtraThemeVideo": "Theme-Video",
+ "NameExtraTrailer": "Bande-Annonce"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json
index cc4899ed50..db4710ddca 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -96,18 +96,18 @@
"External": "Išorinis",
"HearingImpaired": "Su klausos sutrikimais",
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
- "TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įgalintose bibliotekose, Trickplay peržiūras.",
+ "TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įjungtose bibliotekose, Trickplay peržiūras.",
"TaskAudioNormalization": "Garso normalizavimas",
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
- "TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų žodžius",
+ "TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus",
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
- "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
+ "TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus",
"CleanupUserDataTask": "Naudotojo duomenų valymo užduotis",
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).",
- "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}",
+ "LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}",
"NameExtraBehindTheScenes": "Užkulisiuose",
"NameExtraClip": "Klipas",
"NameExtraDeletedScene": "Ištrinta scena",
diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json
index 76fa9e3cf7..52f1eecbe4 100644
--- a/Emby.Server.Implementations/Localization/Core/lv.json
+++ b/Emby.Server.Implementations/Localization/Core/lv.json
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.",
"Original": "Oriģināls",
- "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}"
+ "LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}",
+ "NameExtraBehindTheScenes": "Aiz kadra",
+ "NameExtraClip": "Klips",
+ "NameExtraDeletedScene": "Izdzēsta aina",
+ "NameExtraFeaturette": "Īsfilma",
+ "NameExtraInterview": "Intervija",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Paraugs",
+ "NameExtraScene": "Aina",
+ "NameExtraShort": "Īsfilma",
+ "NameExtraThemeSong": "Motīvu dziesma",
+ "NameExtraThemeVideo": "Tēmas video",
+ "NameExtraTrailer": "Treileris"
}
diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json
index 752b74ec1c..735bc2c793 100644
--- a/Emby.Server.Implementations/Localization/Core/nb.json
+++ b/Emby.Server.Implementations/Localization/Core/nb.json
@@ -106,5 +106,15 @@
"TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.",
"TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.",
"CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.",
- "CleanupUserDataTask": "Oppgave for opprydding av brukerdata"
+ "CleanupUserDataTask": "Oppgave for opprydding av brukerdata",
+ "NameExtraBehindTheScenes": "Bak kulissene",
+ "NameExtraDeletedScene": "Slettet scene",
+ "NameExtraFeaturette": "Presentasjonsfilm",
+ "NameExtraInterview": "Intervju",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Prøve",
+ "NameExtraScene": "Scene",
+ "NameExtraThemeSong": "Tema-låt",
+ "NameExtraThemeVideo": "Tema-video",
+ "NameExtraTrailer": "Trailer"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json
index e609ad52c8..39bbe8bb55 100644
--- a/Emby.Server.Implementations/Localization/Core/pa.json
+++ b/Emby.Server.Implementations/Localization/Core/pa.json
@@ -106,5 +106,6 @@
"TaskRefreshTrickplayImagesDescription": "ਵੀਡੀਓ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ (ਜੇ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਚੁਣਿਆ ਗਿਆ ਹੈ)।",
"TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।",
"CleanupUserDataTaskDescription": "ਘੱਟੋ-ਘੱਟ 90 ਦਿਨਾਂ ਤੋਂ ਮੌਜੂਦ ਨਾ ਹੋਣ ਵਾਲੇ ਮੀਡੀਆ ਤੋਂ ਸਾਰੇ ਉਪਭੋਗਤਾ ਡੇਟਾ (ਵਾਚ ਸਟੇਟ, ਮਨਪਸੰਦ ਸਟੇਟਸ ਆਦਿ) ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ।",
- "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ"
+ "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ",
+ "LyricDownloadFailureFromForItem": "{1} ਲਈ {0} ਤੋਂ ਬੋਲ ਡਾਊਨਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ ਰਹੇ।"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index 358c19881f..dccec8067d 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -111,5 +111,14 @@
"Original": "Original",
"NameExtraBehindTheScenes": "În culise",
"NameExtraClip": "Clip",
- "NameExtraDeletedScene": "Scenă ștearsă"
+ "NameExtraDeletedScene": "Scenă ștearsă",
+ "NameExtraFeaturette": "Material bonus",
+ "NameExtraInterview": "Interviu",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Monstră",
+ "NameExtraScene": "Scenă",
+ "NameExtraShort": "Scurt",
+ "NameExtraThemeSong": "Audio de Fundal",
+ "NameExtraThemeVideo": "Video de Fundal",
+ "NameExtraTrailer": "Trailer"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index 9573eeefe4..a3ae6139ae 100644
--- a/Emby.Server.Implementations/Localization/Core/sk.json
+++ b/Emby.Server.Implementations/Localization/Core/sk.json
@@ -91,7 +91,7 @@
"Default": "Predvolené",
"TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.",
"TaskOptimizeDatabase": "Optimalizovať databázu",
- "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov prehrávania. Táto úloha môže trvať dlhšiu dobu.",
+ "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov. Táto úloha môže trvať dlhší čas.",
"TaskKeyframeExtractor": "Extraktor kľúčových snímkov",
"External": "Externé",
"HearingImpaired": "Sluchovo postihnutí",
diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json
index a1b5b714af..6ea625d66c 100644
--- a/Emby.Server.Implementations/Localization/Core/sl-SI.json
+++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json
@@ -108,5 +108,17 @@
"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.",
"LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
- "Original": "Original"
+ "Original": "Original",
+ "NameExtraBehindTheScenes": "V zakulisju",
+ "NameExtraClip": "Klip",
+ "NameExtraDeletedScene": "Izbrisan prizor",
+ "NameExtraFeaturette": "Kratek dokumentarec o izdelavi filma",
+ "NameExtraInterview": "Intervju",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "Vzorec",
+ "NameExtraScene": "Prizor",
+ "NameExtraShort": "Kratki film",
+ "NameExtraThemeSong": "Tematska Pesem",
+ "NameExtraThemeVideo": "Tematski Video",
+ "NameExtraTrailer": "Napovednik"
}
diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json
index 716e3ae55d..77e526db74 100644
--- a/Emby.Server.Implementations/Localization/Core/th.json
+++ b/Emby.Server.Implementations/Localization/Core/th.json
@@ -22,20 +22,20 @@
"NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว",
"NameSeasonUnknown": "ไม่ทราบซีซัน",
"NameSeasonNumber": "ซีซัน {0}",
- "NameInstallFailed": "การติดตั้ง {0} ล้มเหลว",
+ "NameInstallFailed": "ติดตั้ง {0} ไม่สำเร็จ",
"MusicVideos": "มิวสิควิดีโอ",
- "Music": "ดนตรี",
+ "Music": "เพลง",
"Movies": "ภาพยนตร์",
- "MixedContent": "เนื้อหาผสม",
- "Latest": "ล่าสุด",
- "LabelRunningTimeValue": "ผ่านไปแล้ว: {0}",
- "LabelIpAddressValue": "ที่อยู่ IP: {0}",
- "Inherit": "สืบทอด",
- "HomeVideos": "โฮมวิดีโอ",
- "HeaderNextUp": "ถัดไป",
- "HeaderLiveTV": "ทีวีสด",
- "HeaderFavoriteShows": "รายการที่ชื่นชอบ",
- "HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ",
+ "MixedContent": "เนื้อหาหลากหลายประเภท",
+ "Latest": "มาใหม่ล่าสุด",
+ "LabelRunningTimeValue": "ความยาว: {0}",
+ "LabelIpAddressValue": "หมายเลข IP: {0}",
+ "Inherit": "ใช้ค่าเริ่มต้น",
+ "HomeVideos": "วิดีโอส่วนตัว",
+ "HeaderNextUp": "รายการถัดไป",
+ "HeaderLiveTV": "ทีวีถ่ายทอดสด",
+ "HeaderFavoriteShows": "รายการที่ชอบ",
+ "HeaderFavoriteEpisodes": "ตอนที่ชอบ",
"HeaderContinueWatching": "ดูต่อ",
"Genres": "ประเภท",
"Folders": "โฟลเดอร์",
@@ -107,6 +107,19 @@
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
- "LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
- "Original": "ต้นฉบับ"
+ "LyricDownloadFailureFromForItem": "ดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1} ไม่สำเร็จ",
+ "Original": "ต้นฉบับ",
+ "NameExtraBehindTheScenes": "เบื้องหลังการถ่ายทำ",
+ "NameExtraClip": "คลิปวิดีโอ",
+ "NameExtraDeletedScene": "ฉากที่ถูกตัดออก",
+ "NameExtraFeaturette": "คลิปสั้นพิเศษ",
+ "NameExtraInterview": "บทสัมภาษณ์",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "ตัวอย่าง",
+ "NameExtraScene": "ฉาก",
+ "NameExtraShort": "ภาพยนตร์สั้น",
+ "NameExtraThemeSong": "เพลงประกอบ",
+ "NameExtraThemeVideo": "วิดีโอธีม",
+ "NameExtraTrailer": "ตัวอย่างภาพยนตร์",
+ "NameExtraUnknown": "เนื้อหาพิเศษ"
}
diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json
index 67f4ecc4d5..87f49ae80d 100644
--- a/Emby.Server.Implementations/Localization/Core/tr.json
+++ b/Emby.Server.Implementations/Localization/Core/tr.json
@@ -81,8 +81,8 @@
"TaskCleanLogs": "Günlük Dizinini Temizle",
"TaskRefreshLibraryDescription": "Medya kütüphanenize eklenen yeni dosyaları arar ve ortam bilgilerini yeniler.",
"TaskRefreshLibrary": "Medya Kütüphanesini Tara",
- "TaskRefreshChapterImagesDescription": "Bölümlere ayrılmış videolar için küçük resimler oluştur.",
- "TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar",
+ "TaskRefreshChapterImagesDescription": "Video bölümleri için küçük görseller oluştur.",
+ "TaskRefreshChapterImages": "Bölüm görsellerini çıkar",
"TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.",
"TaskCleanActivityLog": "Etkinlik Günlüğünü Temizle",
"TaskCleanActivityLogDescription": "Yapılandırılan tarihten daha eski olan etkinlik günlüğü girişlerini siler.",
diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json
index b3f24a31e3..a8ec31aed4 100644
--- a/Emby.Server.Implementations/Localization/Core/ur_PK.json
+++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json
@@ -98,5 +98,28 @@
"TaskDownloadMissingLyrics": "غائب بول ڈاؤن لوڈ کریں",
"TaskDownloadMissingLyricsDescription": "گانے کے غائب بول ڈاؤن لوڈ کریں",
"TaskAudioNormalization": "آڈیو نارملائزیشن",
- "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔"
+ "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔",
+ "LyricDownloadFailureFromForItem": "{1} کے لیے {0} سے دھن ڈاؤن لوڈ کرنے میں ناکامی",
+ "Original": "اصل",
+ "TaskRefreshTrickplayImages": "Trickplay تصاویر بنائیں",
+ "TaskRefreshTrickplayImagesDescription": "فعال لائبریریوں میں ویڈیوز کے لیے Trickplay پیش منظر بناتا ہے۔",
+ "TaskExtractMediaSegments": "میڈیا سیگمنٹ اسکین",
+ "TaskExtractMediaSegmentsDescription": "MediaSegment فعال پلگ انز سے میڈیا سیگمنٹس اخذ یا حاصل کرتا ہے۔",
+ "TaskMoveTrickplayImages": "Trickplay تصاویر کا مقام منتقل کریں",
+ "TaskMoveTrickplayImagesDescription": "لائبریری کی ترتیبات کے مطابق موجودہ Trickplay فائلیں منتقل کرتا ہے۔",
+ "CleanupUserDataTask": "صارف ڈیٹا صفائی کا ٹاسک",
+ "CleanupUserDataTaskDescription": "کم از کم 90 دن سے موجود نہ ہونے والے میڈیا سے تمام صارف ڈیٹا (دیکھنے کی حالت، پسندیدہ حیثیت وغیرہ) صاف کرتا ہے۔",
+ "NameExtraBehindTheScenes": "پسِ پردہ",
+ "NameExtraClip": "کلپ",
+ "NameExtraDeletedScene": "حذف شدہ منظر",
+ "NameExtraFeaturette": "فیچریٹ",
+ "NameExtraInterview": "انٹرویو",
+ "NameExtraNumbered": "{0} {1}",
+ "NameExtraSample": "نمونہ",
+ "NameExtraScene": "منظر",
+ "NameExtraShort": "مختصر",
+ "NameExtraThemeSong": "تھیم سونگ",
+ "NameExtraThemeVideo": "تھیم ویڈیو",
+ "NameExtraTrailer": "ٹریلر",
+ "NameExtraUnknown": "اضافی"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json
index 9590d9c1c9..7c61ce6f17 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-CN.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json
@@ -22,7 +22,7 @@
"MixedContent": "混合内容",
"Movies": "电影",
"Music": "音乐",
- "MusicVideos": "MV",
+ "MusicVideos": "音乐视频",
"NameInstallFailed": "{0} 安装失败",
"NameSeasonNumber": "第 {0} 季",
"NameSeasonUnknown": "未知季",
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 0331ec39e5..3f89237ab2 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -139,7 +139,8 @@ namespace Emby.Server.Implementations.Localization
var ratingSystem = await JsonSerializer.DeserializeAsync<ParentalRatingSystem>(stream, _jsonOptions).ConfigureAwait(false)
?? throw new InvalidOperationException($"Invalid resource path: '{CountriesPath}'");
- var dict = new Dictionary<string, ParentalRatingScore?>();
+ // Rating strings are compared case insensitively, providers are not consistent about casing (e.g. "VM18" vs "vm18")
+ var dict = new Dictionary<string, ParentalRatingScore?>(StringComparer.OrdinalIgnoreCase);
if (ratingSystem.Ratings is not null)
{
foreach (var ratingEntry in ratingSystem.Ratings)
@@ -374,12 +375,37 @@ namespace Emby.Server.Implementations.Localization
{
ArgumentException.ThrowIfNullOrEmpty(rating);
+ // Handle unrated content. This has to happen before the split below,
+ // because some of the unrated values contain a '/' themselves (e.g. "n/a").
+ if (IsUnrated(rating))
+ {
+ return null;
+ }
+
+ // Several rating systems contain a '/' inside a single rating (e.g. "M/12" in PT,
+ // "U/A 13+" in IN, "7/i/fig" in ES), so the value as a whole always wins over the split below.
+ var wholeValueScore = GetSingleRatingScore(rating, countryCode);
+ if (wholeValueScore is not null)
+ {
+ return wholeValueScore;
+ }
+
// 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);
+ if (ratingValues.Length == 1)
+ {
+ return null;
+ }
foreach (var ratingValue in ratingValues)
{
+ // A single entry of such a list may be unrated while a later one still resolves
+ if (IsUnrated(ratingValue))
+ {
+ continue;
+ }
+
var score = GetSingleRatingScore(ratingValue, countryCode);
if (score is not null)
{
@@ -391,16 +417,18 @@ namespace Emby.Server.Implementations.Localization
}
/// <summary>
+ /// Checks whether a rating value marks the content as unrated.
+ /// </summary>
+ /// <param name="rating">Rating value to check.</param>
+ /// <returns>Returns true if the value is an unrated marker.</returns>
+ private static bool IsUnrated(ReadOnlySpan<char> rating)
+ => _unratedValues.Contains(rating.Trim(), StringComparison.OrdinalIgnoreCase);
+
+ /// <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))
- {
- return null;
- }
-
// Convert ints directly
// This may override some of the locale specific age ratings (but those always map to the same age)
if (TryParseRatingAsScore(rating, out var ratingAge))
@@ -512,6 +540,12 @@ namespace Emby.Server.Implementations.Localization
return true;
}
+ // Explicitly unrated content (e.g. "IT-NR") is unrated by definition, not a lookup failure
+ if (IsUnrated(ratingPart))
+ {
+ return true;
+ }
+
_logger.LogWarning(
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
rating,
diff --git a/Emby.Server.Implementations/Localization/Ratings/it.json b/Emby.Server.Implementations/Localization/Ratings/it.json
index f2889bf82c..c1d716a46c 100644
--- a/Emby.Server.Implementations/Localization/Ratings/it.json
+++ b/Emby.Server.Implementations/Localization/Ratings/it.json
@@ -3,28 +3,42 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["T"],
+ "ratingStrings": ["T", "PT", "Per tutti"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["6+"],
+ "ratingStrings": ["6+", "VM6", "VM 6", "VM-6", "Vietato ai minori di 6 anni"],
"ratingScore": {
"score": 6,
"subScore": null
}
},
{
- "ratingStrings": ["14+"],
+ "ratingStrings": ["10+", "VM10", "VM 10", "VM-10", "Vietato ai minori di 10 anni"],
+ "ratingScore": {
+ "score": 10,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["12+", "VM12", "VM 12", "VM-12", "Vietato ai minori di 12 anni"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["14+", "VM14", "VM 14", "VM-14", "Vietato ai minori di 14 anni"],
"ratingScore": {
"score": 14,
"subScore": null
}
},
{
- "ratingStrings": ["18+"],
+ "ratingStrings": ["18+", "VM18", "VM 18", "VM-18", "Vietato ai minori di 18 anni"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/no.json b/Emby.Server.Implementations/Localization/Ratings/no.json
index a5e9523163..40c66e3956 100644
--- a/Emby.Server.Implementations/Localization/Ratings/no.json
+++ b/Emby.Server.Implementations/Localization/Ratings/no.json
@@ -10,49 +10,49 @@
}
},
{
- "ratingStrings": ["6"],
+ "ratingStrings": ["6", "6 år"],
"ratingScore": {
"score": 6,
"subScore": null
}
},
{
- "ratingStrings": ["7"],
+ "ratingStrings": ["7", "7 år"],
"ratingScore": {
"score": 7,
"subScore": null
}
},
{
- "ratingStrings": ["9"],
+ "ratingStrings": ["9", "9 år"],
"ratingScore": {
"score": 9,
"subScore": null
}
},
{
- "ratingStrings": ["11"],
+ "ratingStrings": ["11", "11 år"],
"ratingScore": {
"score": 11,
"subScore": null
}
},
{
- "ratingStrings": ["12"],
+ "ratingStrings": ["12", "12 år"],
"ratingScore": {
"score": 12,
"subScore": null
}
},
{
- "ratingStrings": ["15"],
+ "ratingStrings": ["15", "15 år"],
"ratingScore": {
"score": 15,
"subScore": null
}
},
{
- "ratingStrings": ["18"],
+ "ratingStrings": ["18", "18 år"],
"ratingScore": {
"score": 18,
"subScore": null
diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
index 308faed8cc..8208c85222 100644
--- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs
+++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
@@ -371,7 +371,7 @@ namespace Emby.Server.Implementations.Playlists
if (extension.Equals(".wpl", StringComparison.OrdinalIgnoreCase))
{
var playlist = new WplPlaylist();
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new WplPlaylistEntry()
{
@@ -404,7 +404,7 @@ namespace Emby.Server.Implementations.Playlists
else if (extension.Equals(".zpl", StringComparison.OrdinalIgnoreCase))
{
var playlist = new ZplPlaylist();
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new ZplPlaylistEntry()
{
@@ -440,7 +440,7 @@ namespace Emby.Server.Implementations.Playlists
{
IsExtended = true
};
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new M3uPlaylistEntry()
{
@@ -472,7 +472,7 @@ namespace Emby.Server.Implementations.Playlists
IsExtended = true
};
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new M3uPlaylistEntry()
{
@@ -500,7 +500,7 @@ namespace Emby.Server.Implementations.Playlists
else if (extension.Equals(".pls", StringComparison.OrdinalIgnoreCase))
{
var playlist = new PlsPlaylist();
- foreach (var child in item.GetLinkedChildren())
+ foreach (var child in item.GetLinkedChildren(DtoOptions.StoredColumnsOnly))
{
var entry = new PlsPlaylistEntry()
{
diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs
index 8d29d6a512..f7ad9b9498 100644
--- a/Emby.Server.Implementations/Plugins/PluginManager.cs
+++ b/Emby.Server.Implementations/Plugins/PluginManager.cs
@@ -395,29 +395,11 @@ namespace Emby.Server.Implementations.Plugins
var url = new Uri(packageInfo.ImageUrl);
imagePath = Path.Join(path, url.Segments[^1]);
- var fileStream = AsyncFile.OpenWrite(imagePath);
- Stream? downloadStream = null;
- try
+ // The catalog is refreshed on every dashboard visit and rewrites the manifest of
+ // every installed plugin, so only fetch an image that is actually missing.
+ if (!ImageExists(imagePath))
{
- downloadStream = await HttpClientFactory
- .CreateClient(NamedClient.Default)
- .GetStreamAsync(url)
- .ConfigureAwait(false);
-
- await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false);
- }
- catch (HttpRequestException ex)
- {
- _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath);
- imagePath = string.Empty;
- }
- finally
- {
- await fileStream.DisposeAsync().ConfigureAwait(false);
- if (downloadStream is not null)
- {
- await downloadStream.DisposeAsync().ConfigureAwait(false);
- }
+ imagePath = await DownloadImage(url, imagePath).ConfigureAwait(false);
}
}
@@ -456,6 +438,67 @@ namespace Emby.Server.Implementations.Plugins
}
}
+ private static bool ImageExists(string imagePath)
+ {
+ var image = new FileInfo(imagePath);
+
+ // A previous download may have been interrupted, leaving an empty file behind.
+ return image.Exists && image.Length > 0;
+ }
+
+ private async Task<string> DownloadImage(Uri url, string imagePath)
+ {
+ // Download to a temporary file and move it into place, so that neither a failed download
+ // nor a concurrent one can be observed as a partially written image.
+ var tempPath = imagePath + "." + Path.GetRandomFileName();
+
+ try
+ {
+ var fileStream = AsyncFile.Create(tempPath);
+ Stream? downloadStream = null;
+ try
+ {
+ downloadStream = await HttpClientFactory
+ .CreateClient(NamedClient.Default)
+ .GetStreamAsync(url)
+ .ConfigureAwait(false);
+
+ await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false);
+ }
+ finally
+ {
+ await fileStream.DisposeAsync().ConfigureAwait(false);
+ if (downloadStream is not null)
+ {
+ await downloadStream.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ File.Move(tempPath, imagePath, true);
+
+ return imagePath;
+ }
+ catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException)
+ {
+ _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath);
+ TryDeleteFile(tempPath);
+
+ return string.Empty;
+ }
+ }
+
+ private void TryDeleteFile(string path)
+ {
+ try
+ {
+ File.Delete(path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogWarning(ex, "Unable to delete {Path}.", path);
+ }
+ }
+
/// <summary>
/// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path.
/// If no file is found, no reconciliation occurs.
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
index 8d133dc074..687947616f 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
/// <summary>
-/// Optimizes Jellyfin's database by issuing a VACUUM command.
+/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands.
/// </summary>
public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
{
@@ -82,7 +82,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
return;
}
- _logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
+ _logger.LogInformation("Vacuuming and analyzing jellyfin.db...");
try
{
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
index 42835d7ad0..092a621bfc 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Emby.Server.Implementations.Library.Validators;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
@@ -29,6 +30,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILogger<PeopleValidationTask> _logger;
+ private readonly ILogger<PeopleValidator> _validatorLogger;
private readonly IItemTypeLookup _itemTypeLookup;
/// <summary>
@@ -39,6 +41,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
/// <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="validatorLogger">Instance of the <see cref="ILogger{PeopleValidator}"/> interface.</param>
/// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
public PeopleValidationTask(
ILibraryManager libraryManager,
@@ -46,6 +49,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
IDbContextFactory<JellyfinDbContext> dbContextFactory,
IFileSystem fileSystem,
ILogger<PeopleValidationTask> logger,
+ ILogger<PeopleValidator> validatorLogger,
IItemTypeLookup itemTypeLookup)
{
_libraryManager = libraryManager;
@@ -53,6 +57,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_logger = logger;
+ _validatorLogger = validatorLogger;
_itemTypeLookup = itemTypeLookup;
}
@@ -165,7 +170,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
// 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);
+ await new PeopleValidator(_libraryManager, _validatorLogger)
+ .Run(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));
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs
index 31153af20f..dd3da2214a 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs
@@ -107,6 +107,11 @@ public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask
{
_logger.LogError(ex, "Error updating {Name}", package.Name);
}
+ catch (TimeoutException ex)
+ {
+ // One slow download must not abort the updates for the remaining plugins.
+ _logger.LogError(ex, "Error downloading {Name}", package.Name);
+ }
catch (InvalidDataException ex)
{
_logger.LogError(ex, "Error updating {Name}", package.Name);
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 94215bed79..08e2578867 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -1449,6 +1449,8 @@ namespace Emby.Server.Implementations.Session
if (item is IItemByName byName)
{
+ // A by-name item tags containers as well as leaves: a music genre tags its artists,
+ // and a by-name artist row is not a folder, so IsFolder does not exclude it here.
return byName.GetTaggedItems(new InternalItemsQuery(user)
{
IsFolder = false,
@@ -1463,7 +1465,7 @@ namespace Emby.Server.Implementations.Session
},
IsVirtualItem = false,
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
- });
+ }).Where(i => i is not IItemByName);
}
if (item.IsFolder)
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index e81edc82c6..2100c23c45 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -171,7 +171,7 @@ namespace Emby.Server.Implementations.Session
{
await SendForceKeepAlive(webSocket).ConfigureAwait(false);
}
- catch (WebSocketException exception)
+ catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or OperationCanceledException)
{
_logger.LogWarning(exception, "Cannot send ForceKeepAlive message to WebSocket {0}.", webSocket);
}
@@ -232,7 +232,7 @@ namespace Emby.Server.Implementations.Session
{
await SendForceKeepAlive(webSocket).ConfigureAwait(false);
}
- catch (WebSocketException exception)
+ catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or OperationCanceledException)
{
_logger.LogInformation(exception, "Error sending ForceKeepAlive message to WebSocket.");
lost.Add(webSocket);
diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs
index 38a0018a70..6fbe46ffd6 100644
--- a/Emby.Server.Implementations/SyncPlay/Group.cs
+++ b/Emby.Server.Implementations/SyncPlay/Group.cs
@@ -11,6 +11,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Controller.SyncPlay;
using MediaBrowser.Controller.SyncPlay.GroupStates;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
using MediaBrowser.Controller.SyncPlay.Queue;
using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
@@ -27,6 +28,11 @@ namespace Emby.Server.Implementations.SyncPlay
public class Group : IGroupStateContext
{
/// <summary>
+ /// The default value of <see cref="GroupWaitTimeout"/>, in milliseconds.
+ /// </summary>
+ internal const long DefaultGroupWaitTimeout = 30000;
+
+ /// <summary>
/// The logger.
/// </summary>
private readonly ILogger<Group> _logger;
@@ -54,8 +60,12 @@ namespace Emby.Server.Implementations.SyncPlay
/// <summary>
/// The participants, or members of the group.
/// </summary>
- private readonly Dictionary<string, GroupMember> _participants =
- new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary<string, GroupMember> _participants = new(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// The sessions of the participants, which only carry identifiers.
+ /// </summary>
+ private readonly Dictionary<string, SessionInfo> _participantSessions = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The internal group state.
@@ -91,6 +101,18 @@ namespace Emby.Server.Implementations.SyncPlay
public long DefaultPing { get; } = 500;
/// <summary>
+ /// Gets the maximum ping, in milliseconds, accepted from a session.
+ /// </summary>
+ /// <remarks>
+ /// Pings are reported by clients and are scaled into the delays used to schedule playback,
+ /// so an unbounded value lets a single session push the whole group's resume point
+ /// arbitrarily far out, or overflow the arithmetic entirely. Anything above this is not a
+ /// usable measurement for synchronisation.
+ /// </remarks>
+ /// <value>The maximum ping.</value>
+ public long MaxPing { get; } = 10000;
+
+ /// <summary>
/// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
/// </summary>
/// <value>The maximum time offset error.</value>
@@ -103,6 +125,19 @@ namespace Emby.Server.Implementations.SyncPlay
public long MaxPlaybackOffset { get; } = 500;
/// <summary>
+ /// Gets the maximum time, in milliseconds, the group waits for its members to report ready.
+ /// </summary>
+ /// <value>The group-wait timeout.</value>
+ internal long GroupWaitTimeout { get; init; } = DefaultGroupWaitTimeout;
+
+ /// <summary>
+ /// Gets the <see cref="Environment.TickCount64"/> value at which the group gives up waiting
+ /// for its members, or <c>null</c> when it is not waiting for anyone.
+ /// </summary>
+ /// <value>The group-wait deadline.</value>
+ internal long? GroupWaitDeadline { get; private set; }
+
+ /// <summary>
/// Gets the group identifier.
/// </summary>
/// <value>The group identifier.</value>
@@ -151,6 +186,8 @@ namespace Emby.Server.Implementations.SyncPlay
Ping = DefaultPing,
IsBuffering = false
});
+
+ _participantSessions[session.Id] = session;
}
/// <summary>
@@ -160,6 +197,8 @@ namespace Emby.Server.Implementations.SyncPlay
private void RemoveSession(SessionInfo session)
{
_participants.Remove(session.Id);
+ _participantSessions.Remove(session.Id);
+ UpdateGroupWaitDeadline(false);
}
/// <summary>
@@ -377,13 +416,20 @@ namespace Emby.Server.Implementations.SyncPlay
{
value.IgnoreGroupWait = ignoreGroupWait;
}
+
+ UpdateGroupWaitDeadline(false);
}
/// <inheritdoc />
public void SetState(IGroupState state)
{
_logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type);
- this._state = state;
+ _state = state;
+
+ if (state.Type != GroupStateType.Waiting)
+ {
+ GroupWaitDeadline = null;
+ }
}
/// <inheritdoc />
@@ -438,7 +484,7 @@ namespace Emby.Server.Implementations.SyncPlay
{
if (_participants.TryGetValue(session.Id, out GroupMember value))
{
- value.Ping = ping;
+ value.Ping = Math.Clamp(ping, 0, MaxPing);
}
}
@@ -451,7 +497,9 @@ namespace Emby.Server.Implementations.SyncPlay
max = Math.Max(max, session.Ping);
}
- return max;
+ // A group with no participants has no ping to report. Returning long.MinValue would
+ // overflow the callers that scale this value into ticks, so fall back to the default.
+ return max == long.MinValue ? DefaultPing : max;
}
/// <inheritdoc />
@@ -461,6 +509,8 @@ namespace Emby.Server.Implementations.SyncPlay
{
value.IsBuffering = isBuffering;
}
+
+ UpdateGroupWaitDeadline(false);
}
/// <inheritdoc />
@@ -470,6 +520,9 @@ namespace Emby.Server.Implementations.SyncPlay
{
session.IsBuffering = isBuffering;
}
+
+ // Resetting the status of every session starts a new waiting period.
+ UpdateGroupWaitDeadline(isBuffering);
}
/// <inheritdoc />
@@ -676,5 +729,85 @@ namespace Emby.Server.Implementations.SyncPlay
PlayQueue.ShuffleMode,
PlayQueue.RepeatMode);
}
+
+ /// <summary>
+ /// Stops waiting for the members that have not reported ready and lets the rest of the
+ /// group carry on. Does nothing until <see cref="GroupWaitDeadline"/> has passed.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ internal void HandleGroupWaitTimeout(CancellationToken cancellationToken)
+ {
+ var deadline = GroupWaitDeadline;
+ if (deadline is null || deadline > Environment.TickCount64)
+ {
+ return;
+ }
+
+ GroupWaitDeadline = null;
+
+ if (_state is not WaitingGroupState waitingState)
+ {
+ return;
+ }
+
+ var blockingSessions = _participantSessions
+ .Values
+ .Where(participant => _participants.TryGetValue(participant.Id, out var member)
+ && member.IsBuffering
+ && !member.IgnoreGroupWait)
+ .ToList();
+
+ if (blockingSessions.Count == 0)
+ {
+ return;
+ }
+
+ // The recovery below is broadcast to the whole group, so it does not matter which of
+ // the sessions that kept the group waiting is the one acting on the group's behalf.
+ var session = blockingSessions[0];
+
+ _logger.LogWarning(
+ "Group {GroupId} waited {Waited} ms for session(s) {SessionIds} to report ready, giving up.",
+ GroupId.ToString(),
+ GroupWaitTimeout + Environment.TickCount64 - deadline.Value,
+ string.Join(", ", blockingSessions.Select(participant => participant.Id)));
+
+ if (waitingState.ResumePlaying)
+ {
+ // An unpause request in the waiting state means "start now, ignoring the sessions
+ // that are not ready".
+ var unpauseRequest = new UnpauseGroupRequest();
+ waitingState.HandleRequest(unpauseRequest, this, GroupStateType.Waiting, session, cancellationToken);
+ return;
+ }
+
+ // The members have been paused for the whole waiting period, so the playback position
+ // stays where the wait started.
+ SetAllBuffering(false);
+ SetState(new PausedGroupState(_loggerFactory));
+
+ var command = NewSyncPlayCommand(SendCommandType.Pause);
+ SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ var stateUpdate = new GroupStateUpdate(GroupStateType.Paused, PlaybackRequestType.Pause);
+ var update = new SyncPlayStateUpdate(GroupId, stateUpdate);
+ SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ private void UpdateGroupWaitDeadline(bool startNewWaitingPeriod)
+ {
+ if (_state.Type != GroupStateType.Waiting || !IsBuffering())
+ {
+ GroupWaitDeadline = null;
+ return;
+ }
+
+ // A running deadline covers the waiting period as a whole, so the sessions that keep
+ // reporting buffering while they load must not push it back.
+ if (GroupWaitDeadline is null || startNewWaitingPeriod)
+ {
+ GroupWaitDeadline = Environment.TickCount64 + GroupWaitTimeout;
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
index b45d754554..88dfb070b8 100644
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
+++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
@@ -19,6 +19,11 @@ namespace Emby.Server.Implementations.SyncPlay
public class SyncPlayManager : ISyncPlayManager, IDisposable
{
/// <summary>
+ /// How often, in milliseconds, the groups are checked for a spent wait deadline.
+ /// </summary>
+ private const int GroupWaitSweepInterval = 1000;
+
+ /// <summary>
/// The logger.
/// </summary>
private readonly ILogger<SyncPlayManager> _logger;
@@ -69,6 +74,11 @@ namespace Emby.Server.Implementations.SyncPlay
/// </remarks>
private readonly Lock _groupsLock = new();
+ /// <summary>
+ /// The timer that watches the groups' wait deadlines, running only while there are groups.
+ /// </summary>
+ private readonly Timer _groupWaitTimer;
+
private bool _disposed = false;
/// <summary>
@@ -90,8 +100,15 @@ namespace Emby.Server.Implementations.SyncPlay
_libraryManager = libraryManager;
_logger = loggerFactory.CreateLogger<SyncPlayManager>();
_sessionManager.SessionEnded += OnSessionEnded;
+ _groupWaitTimer = new Timer(_ => OnGroupWaitTimerTick(), null, Timeout.Infinite, Timeout.Infinite);
}
+ /// <summary>
+ /// Gets the maximum time, in milliseconds, a group waits for its members to report ready.
+ /// </summary>
+ /// <value>The group-wait timeout.</value>
+ internal long GroupWaitTimeout { get; init; } = Group.DefaultGroupWaitTimeout;
+
/// <inheritdoc />
public void Dispose()
{
@@ -122,8 +139,12 @@ namespace Emby.Server.Implementations.SyncPlay
LeaveGroup(session, leaveGroupRequest, cancellationToken);
}
- var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager);
+ var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager)
+ {
+ GroupWaitTimeout = GroupWaitTimeout
+ };
_groups[group.GroupId] = group;
+ UpdateGroupWaitTimer();
if (!_sessionToGroupMap.TryAdd(session.Id, group))
{
@@ -181,8 +202,8 @@ namespace Emby.Server.Implementations.SyncPlay
{
if (existingGroup.GroupId.Equals(request.GroupId))
{
- // Restore session.
- UpdateSessionsCounter(session.UserId, 1);
+ // Restore session. The session is already in the group and has already
+ // been counted, so the counter must not be incremented a second time.
group.SessionJoin(session, request, cancellationToken);
return;
}
@@ -242,6 +263,7 @@ namespace Emby.Server.Implementations.SyncPlay
{
_logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId);
_groups.Remove(group.GroupId, out _);
+ UpdateGroupWaitTimer();
}
}
}
@@ -332,8 +354,11 @@ namespace Emby.Server.Implementations.SyncPlay
// Group lock required as Group is not thread-safe.
lock (group)
{
- // Make sure that session still belongs to this group.
- if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId))
+ // Make sure that session still belongs to this group. The lookup can fail
+ // outright when the session left while this request was waiting on the group
+ // lock, which is exactly the case this re-check exists to catch.
+ if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup)
+ || !checkGroup.GroupId.Equals(group.GroupId))
{
// Drop request.
return;
@@ -381,7 +406,50 @@ namespace Emby.Server.Implementations.SyncPlay
}
_sessionManager.SessionEnded -= OnSessionEnded;
- _disposed = true;
+
+ lock (_groupsLock)
+ {
+ _disposed = true;
+ _groupWaitTimer.Dispose();
+ }
+ }
+
+ private void UpdateGroupWaitTimer()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ var interval = _groups.IsEmpty ? Timeout.Infinite : GroupWaitSweepInterval;
+ _groupWaitTimer.Change(interval, interval);
+ }
+
+ private void OnGroupWaitTimerTick()
+ {
+ try
+ {
+ lock (_groupsLock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ foreach (var (_, group) in _groups)
+ {
+ // Group lock required as Group is not thread-safe.
+ lock (group)
+ {
+ group.HandleGroupWaitTimeout(CancellationToken.None);
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error while recovering SyncPlay groups from a timed out wait.");
+ }
}
private void OnSessionEnded(object sender, SessionEventArgs e)
@@ -400,7 +468,7 @@ namespace Emby.Server.Implementations.SyncPlay
// Update sessions counter.
var newSessionsCounter = _activeUsers.AddOrUpdate(
userId,
- 1,
+ toAdd,
(_, sessionsCounter) => sessionsCounter + toAdd);
// Should never happen.
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index 174234b96b..cccdb3e6aa 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -11,7 +11,6 @@ using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
-using Jellyfin.Data.Events;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
@@ -34,6 +33,9 @@ namespace Emby.Server.Implementations.Updates
public class InstallationManager : IInstallationManager
{
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
+ // Budget for the whole package download. The response headers are already bounded by the
+ // HttpClient timeout; this covers reading the package body, which can be large and slow.
+ private static readonly TimeSpan PackageDownloadTimeout = TimeSpan.FromMinutes(10);
/// <summary>
/// The logger.
@@ -82,8 +84,8 @@ namespace Emby.Server.Implementations.Updates
IServerConfigurationManager config,
IPluginManager pluginManager)
{
- _currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
- _completedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
+ _currentInstallations = [];
+ _completedInstallationsInternal = [];
_logger = logger;
_applicationHost = appHost;
@@ -341,8 +343,9 @@ namespace Emby.Server.Implementations.Updates
_applicationHost.NotifyPendingRestart();
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (linkedToken.IsCancellationRequested)
{
+ // Only an actually cancelled token is a cancellation.
lock (_currentInstallationsLock)
{
_currentInstallations.Remove(tuple);
@@ -356,7 +359,7 @@ namespace Emby.Server.Implementations.Updates
}
catch (Exception ex)
{
- _logger.LogError(ex, "Package installation failed");
+ _logger.LogError(ex, "Package installation failed: {Name} {Version}", package.Name, package.Version);
lock (_currentInstallationsLock)
{
@@ -546,12 +549,36 @@ namespace Emby.Server.Implementations.Updates
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();
- Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
- await using (stream.ConfigureAwait(false))
+ // ResponseHeadersRead keeps the body out of the HttpClient timeout, which otherwise covers
+ // the whole download; the package gets the longer budget below instead.
+ using var downloadTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ downloadTokenSource.CancelAfter(PackageDownloadTimeout);
+ var downloadToken = downloadTokenSource.Token;
+
+ var buffer = new MemoryStream();
+ await using (buffer.ConfigureAwait(false))
{
+ try
+ {
+ using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .GetAsync(new Uri(package.SourceUrl), HttpCompletionOption.ResponseHeadersRead, downloadToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ // The package is read twice, for the checksum and for the extraction, so it has
+ // to be buffered: the response stream is not seekable.
+ await response.Content.CopyToAsync(buffer, downloadToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ // Either our budget above or the HttpClient timeout ran out.
+ throw new TimeoutException(
+ $"Downloading the package {package.Name} {package.Version} from {package.SourceUrl} timed out.",
+ ex);
+ }
+
+ buffer.Position = 0;
+ Stream stream = buffer;
+
// CA5351: Do Not Use Broken Cryptographic Algorithms
#pragma warning disable CA5351
cancellationToken.ThrowIfCancellationRequested();
diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
index 7efb5b1698..76874d10de 100644
--- a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
+++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
@@ -2,6 +2,7 @@ using System.Threading.Tasks;
using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.SyncPlay;
@@ -34,6 +35,13 @@ namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement)
{
var userId = context.User.GetUserId();
+ if (userId.IsEmpty())
+ {
+ // Unauthenticated requests and API keys carry no user, so there is nothing to
+ // check: leave the requirement unsatisfied and let the request be challenged.
+ return Task.CompletedTask;
+ }
+
var user = _userManager.GetUserById(userId);
if (user is null)
{
diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs
index b458bd90f3..b9ca45cc1c 100644
--- a/Jellyfin.Api/Controllers/FilterController.cs
+++ b/Jellyfin.Api/Controllers/FilterController.cs
@@ -178,16 +178,35 @@ public class FilterController : BaseJellyfinApiController
IsSeries = isSeries
};
+ var tagQuery = new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = includeItemTypes,
+ DtoOptions = new DtoOptions
+ {
+ Fields = Array.Empty<ItemFields>(),
+ EnableImages = false,
+ EnableUserData = false
+ },
+ IsAiring = isAiring,
+ IsMovie = isMovie,
+ IsSports = isSports,
+ IsKids = isKids,
+ IsNews = isNews,
+ IsSeries = isSeries
+ };
+
if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
{
var ancestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
genreQuery.AncestorIds = ancestorIds;
streamLanguageQuery.AncestorIds = ancestorIds;
+ tagQuery.AncestorIds = ancestorIds;
}
else
{
genreQuery.Parent = parentItem;
streamLanguageQuery.Parent = parentItem;
+ tagQuery.Parent = parentItem;
}
if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season))
@@ -218,6 +237,8 @@ public class FilterController : BaseJellyfinApiController
}).ToArray();
}
+ filters.Tags = _libraryManager.GetTagNames(tagQuery);
+
if (includeItemTypes.Contains(BaseItemKind.Movie)
|| includeItemTypes.Contains(BaseItemKind.Series)
|| includeItemTypes.Contains(BaseItemKind.Season)
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index 5c596c21b9..7f63e410a5 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -16,6 +16,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authorization;
@@ -34,6 +35,7 @@ public class LibraryStructureController : BaseJellyfinApiController
private readonly IServerApplicationPaths _appPaths;
private readonly ILibraryManager _libraryManager;
private readonly ILibraryMonitor _libraryMonitor;
+ private readonly IDirectoryService _directoryService;
/// <summary>
/// Initializes a new instance of the <see cref="LibraryStructureController"/> class.
@@ -41,14 +43,17 @@ public class LibraryStructureController : BaseJellyfinApiController
/// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
/// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param>
+ /// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param>
public LibraryStructureController(
IServerConfigurationManager serverConfigurationManager,
ILibraryManager libraryManager,
- ILibraryMonitor libraryMonitor)
+ ILibraryMonitor libraryMonitor,
+ IDirectoryService directoryService)
{
_appPaths = serverConfigurationManager.ApplicationPaths;
_libraryManager = libraryManager;
_libraryMonitor = libraryMonitor;
+ _directoryService = directoryService;
}
/// <summary>
@@ -178,11 +183,11 @@ public class LibraryStructureController : BaseJellyfinApiController
var tempPath = Path.Combine(
rootFolderPath,
Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
- Directory.Move(currentPath, tempPath);
+ _directoryService.Move(currentPath, tempPath);
currentPath = tempPath;
}
- Directory.Move(currentPath, newPath);
+ _directoryService.Move(currentPath, newPath);
}
finally
{
@@ -208,7 +213,7 @@ public class LibraryStructureController : BaseJellyfinApiController
{
_libraryManager.ClearIgnoreRuleCache();
// We don't know if this one can be validated individually, trigger a new validation
- await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ _libraryManager.QueueLibraryScan();
}
_libraryManager.ClearIgnoreRuleCache();
@@ -255,7 +260,7 @@ public class LibraryStructureController : BaseJellyfinApiController
// No need to start if scanning the library because it will handle it
if (refreshLibrary)
{
- await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ _libraryManager.QueueLibraryScan();
}
else
{
@@ -322,7 +327,7 @@ public class LibraryStructureController : BaseJellyfinApiController
// No need to start if scanning the library because it will handle it
if (refreshLibrary)
{
- await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ _libraryManager.QueueLibraryScan();
}
else
{
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
index 1f8f963f70..10bcf4e717 100644
--- a/Jellyfin.Api/Controllers/PackageController.cs
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -3,7 +3,9 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Api;
+using MediaBrowser.Common.Plugins;
using MediaBrowser.Common.Updates;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates;
@@ -23,16 +25,22 @@ public class PackageController : BaseJellyfinApiController
{
private readonly IInstallationManager _installationManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly IPluginManager _pluginManager;
/// <summary>
/// Initializes a new instance of the <see cref="PackageController"/> class.
/// </summary>
/// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
- public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager)
+ /// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param>
+ public PackageController(
+ IInstallationManager installationManager,
+ IServerConfigurationManager serverConfigurationManager,
+ IPluginManager pluginManager)
{
_installationManager = installationManager;
_serverConfigurationManager = serverConfigurationManager;
+ _pluginManager = pluginManager;
}
/// <summary>
@@ -48,6 +56,13 @@ public class PackageController : BaseJellyfinApiController
[FromRoute, Required] string name,
[FromQuery] Guid? assemblyGuid)
{
+ // Plugins bundled with the server are not published to any repository, so querying
+ // the configured repositories for them can only ever fail, and does so slowly.
+ if (IsBundledPlugin(name, assemblyGuid))
+ {
+ return NotFound();
+ }
+
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
var result = _installationManager.FilterPackages(
packages,
@@ -96,6 +111,11 @@ public class PackageController : BaseJellyfinApiController
[FromQuery] string? version,
[FromQuery] string? repositoryUrl)
{
+ if (IsBundledPlugin(name, assemblyGuid))
+ {
+ return NotFound();
+ }
+
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
if (!string.IsNullOrEmpty(repositoryUrl))
{
@@ -161,4 +181,13 @@ public class PackageController : BaseJellyfinApiController
_serverConfigurationManager.SaveConfiguration();
return NoContent();
}
+
+ private bool IsBundledPlugin(string name, Guid? assemblyGuid)
+ {
+ var plugin = assemblyGuid is Guid id && !id.IsEmpty()
+ ? _pluginManager.GetPlugin(id)
+ : _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+
+ return plugin?.Instance?.CanUninstall == false;
+ }
}
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index e5df873f5b..c4851091c1 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -557,7 +557,7 @@ public class SubtitleController : BaseJellyfinApiController
if (!string.IsNullOrEmpty(fallbackFontPath))
{
var fontFile = _fileSystem.GetFiles(fallbackFontPath)
- .First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
+ .FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
var fileSize = fontFile?.Length;
if (fontFile is not null && fileSize is not null && fileSize > 0)
diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs
index 8b359c48af..a934e5dbf8 100644
--- a/Jellyfin.Api/Controllers/UserViewsController.cs
+++ b/Jellyfin.Api/Controllers/UserViewsController.cs
@@ -90,7 +90,7 @@ public class UserViewsController : BaseJellyfinApiController
var dtoOptions = new DtoOptions();
dtoOptions.Fields = [.. dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId];
- var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user));
+ var dtos = _dtoService.GetBaseItemDtos(folders, dtoOptions, user, skipVisibilityCheck: true);
return new QueryResult<BaseItemDto>(dtos);
}
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
index b09b279699..4995d37f4b 100644
--- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
+++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
@@ -458,6 +458,7 @@ public class DynamicHlsHelper
{
case VideoRangeType.HLG:
case VideoRangeType.DOVIWithHLG:
+ case VideoRangeType.DOVIInvalid when string.Equals(state.VideoStream.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase):
builder.Append(",VIDEO-RANGE=HLG");
break;
default:
diff --git a/Jellyfin.Data/Enums/VideoRangeType.cs b/Jellyfin.Data/Enums/VideoRangeType.cs
index ce232d73c3..e7cc340d21 100644
--- a/Jellyfin.Data/Enums/VideoRangeType.cs
+++ b/Jellyfin.Data/Enums/VideoRangeType.cs
@@ -61,8 +61,9 @@ public enum VideoRangeType
DOVIWithELHDR10Plus,
/// <summary>
- /// Dolby Vision with invalid configuration. e.g. Profile 8 compat id 6.
- /// When using this range, the server would assume the video is still HDR10 after removing the Dolby Vision metadata.
+ /// Dolby Vision with invalid configuration, e.g. Profile 8 compat id 6 or inconsistent base-layer color metadata.
+ /// The base layer is classified as HDR only when its transfer characteristics signal PQ or HLG.
+ /// Otherwise, it is classified as SDR.
/// </summary>
DOVIInvalid,
diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj
index c8983480c7..4b42409d29 100644
--- a/Jellyfin.Data/Jellyfin.Data.csproj
+++ b/Jellyfin.Data/Jellyfin.Data.csproj
@@ -18,7 +18,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId>
- <VersionPrefix>12.0.0</VersionPrefix>
+ <VersionPrefix>13.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
index c2cb644c59..6405c8c45d 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
@@ -34,6 +34,40 @@ public static class BaseItemMapper
/// </summary>
private static readonly ConcurrentDictionary<string, Type?> _typeMap = new ConcurrentDictionary<string, Type?>();
+ private static UserData[] DetachUserData(BaseItemEntity entity)
+ {
+ if (entity.UserData is null || entity.UserData.Count == 0)
+ {
+ return [];
+ }
+
+ var detached = new UserData[entity.UserData.Count];
+ var index = 0;
+ foreach (var userData in entity.UserData)
+ {
+ detached[index++] = new UserData
+ {
+ ItemId = userData.ItemId,
+ Item = null,
+ UserId = userData.UserId,
+ User = null,
+ CustomDataKey = userData.CustomDataKey,
+ Rating = userData.Rating,
+ PlaybackPositionTicks = userData.PlaybackPositionTicks,
+ PlayCount = userData.PlayCount,
+ IsFavorite = userData.IsFavorite,
+ LastPlayedDate = userData.LastPlayedDate,
+ Played = userData.Played,
+ AudioStreamIndex = userData.AudioStreamIndex,
+ SubtitleStreamIndex = userData.SubtitleStreamIndex,
+ Likes = userData.Likes,
+ RetentionDate = userData.RetentionDate
+ };
+ }
+
+ return detached;
+ }
+
/// <summary>
/// Maps a Entity to the DTO.
/// </summary>
@@ -87,7 +121,7 @@ public static class BaseItemMapper
dto.OwnerId = entity.OwnerId ?? Guid.Empty;
dto.Width = entity.Width.GetValueOrDefault();
dto.Height = entity.Height.GetValueOrDefault();
- dto.UserData = entity.UserData;
+ dto.UserData = DetachUserData(entity);
if (entity.Provider is not null)
{
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index 2ff8131ade..51ac146a6f 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -117,24 +117,55 @@ public sealed partial class BaseItemRepository
.ToArray();
}
- private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
+ /// <inheritdoc />
+ public IReadOnlyList<string> GetTagNames(InternalItemsQuery filter)
{
+ ArgumentNullException.ThrowIfNull(filter);
+ PrepareFilterQuery(filter);
+
using var context = _dbProvider.CreateDbContext();
+ var baseQuery = PrepareItemQuery(context, filter);
+ baseQuery = TranslateQuery(baseQuery, context, filter);
- var query = context.ItemValuesMap
+ var matchingItemIds = baseQuery.Select(e => e.Id);
+
+ // Project the join before grouping. Grouping over the ItemValue navigation instead makes EF
+ // re-resolve the aggregate as a correlated subquery per group, which is orders of magnitude slower.
+ return context.ItemValuesMap
.AsNoTracking()
- .Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type));
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Tags)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
+ .GroupBy(iv => iv.CleanValue)
+ .Select(g => g.Min(iv => iv.Value)!)
+ .OrderBy(t => t)
+ .ToArray();
+ }
+
+ private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
+ {
+ using var context = _dbProvider.CreateDbContext();
+
+ var maps = context.ItemValuesMap.AsNoTracking();
if (withItemTypes.Count > 0)
{
- query = query.Where(e => withItemTypes.Contains(e.Item.Type));
+ maps = maps.Where(e => withItemTypes.Contains(e.Item.Type));
}
if (excludeItemTypes.Count > 0)
{
- query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type));
+ maps = maps.Where(e => !excludeItemTypes.Contains(e.Item.Type));
}
- return query.Select(e => e.ItemValue)
+ return context.ItemValues
+ .AsNoTracking()
+ .WhereOneOrMany(itemValueTypes, e => e.Type)
+ .Where(e => maps.Any(m => m.ItemValueId == e.ItemValueId))
+ .Select(e => new { e.CleanValue, e.Value })
.GroupBy(e => e.CleanValue)
.Select(g => g.Min(v => v.Value)!)
.ToArray();
@@ -317,14 +348,7 @@ public sealed partial class BaseItemRepository
.WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
- var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
- var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
- var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
- var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo];
- var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram];
- var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
- var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
// Instead, start from ItemValueMaps and join with BaseItems.
@@ -333,9 +357,9 @@ public sealed partial class BaseItemRepository
scopedItems,
ivm => ivm.ItemId,
e => e.Id,
- (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId })
+ (ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId, e.Id })
.GroupBy(x => new { x.CleanName, x.Type, x.SeriesId })
- .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() })
+ .Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Select(x => x.Id).Distinct().Count() })
.ToList();
// Only studios and genres pass down from a series to its episodes; an artist credit does not.
@@ -357,46 +381,10 @@ public sealed partial class BaseItemRepository
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
{
- var counts = new ItemCounts();
- foreach (var row in group)
- {
- if (row.Type == seriesTypeName)
- {
- counts.SeriesCount += row.Count;
- }
- else if (row.Type == movieTypeName)
- {
- counts.MovieCount += row.Count;
- }
- else if (row.Type == musicAlbumTypeName)
- {
- counts.AlbumCount += row.Count;
- }
- else if (row.Type == musicArtistTypeName)
- {
- counts.ArtistCount += row.Count;
- }
- else if (row.Type == musicVideoTypeName)
- {
- counts.MusicVideoCount += row.Count;
- }
- else if (row.Type == programTypeName)
- {
- counts.ProgramCount += row.Count;
- }
- else if (row.Type == audioTypeName)
- {
- counts.SongCount += row.Count;
- }
- else if (row.Type == trailerTypeName)
- {
- counts.TrailerCount += row.Count;
- }
- }
+ var counts = ItemCountBuilder.Build(_itemTypeLookup, group.Select(row => (row.Type, row.Count)));
// Episodes are counted separately: the value is usually only written on the series.
- counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key);
- counts.ItemCount = counts.TotalItemCount();
+ ItemCountBuilder.SetEpisodeCount(counts, episodeCounts.GetValueOrDefault(group.Key));
countsByCleanName[group.Key] = counts;
}
@@ -405,7 +393,9 @@ public sealed partial class BaseItemRepository
{
if (!countsByCleanName.ContainsKey(cleanName))
{
- countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount };
+ var counts = new ItemCounts();
+ ItemCountBuilder.SetEpisodeCount(counts, episodeCount);
+ countsByCleanName[cleanName] = counts;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
index c0067d8392..1a4c9da41c 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
@@ -271,7 +271,7 @@ public sealed partial class BaseItemRepository
if (filter.DtoOptions.EnableImages)
{
- dbQuery = dbQuery.Include(e => e.Images);
+ dbQuery = dbQuery.Include(e => e.Images!.OrderBy(i => i.Id));
}
// Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist,
@@ -291,7 +291,7 @@ public sealed partial class BaseItemRepository
};
if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains))
{
- dbQuery = dbQuery.Include(e => e.LinkedChildEntities);
+ dbQuery = dbQuery.Include(e => e.LinkedChildEntities!.OrderBy(l => l.SortOrder));
}
if (filter.IncludeExtras)
@@ -465,16 +465,23 @@ public sealed partial class BaseItemRepository
baseQuery = ApplyParentalRestrictions(context, baseQuery, filter);
- // Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
- // Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
+ // Hide alternate versions behind the primary of their library, and exclude owned non-extra
+ // items. Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
if (!filter.IncludeOwnedItems)
{
- baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
+ baseQuery = ApplyAlternateVersionFiltering(context, baseQuery)
+ .Where(e => e.OwnerId == null || e.ExtraType != null);
}
return baseQuery;
}
+ private static IQueryable<BaseItemEntity> ApplyAlternateVersionFiltering(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> baseQuery)
+ => baseQuery.Where(e => e.PrimaryVersionId == null
+ || !context.BaseItems.Any(p => p.Id == e.PrimaryVersionId && p.TopParentId == e.TopParentId));
+
/// <summary>
/// Restricts a query to the libraries the user may open, exempting requested by-name items.
/// </summary>
@@ -645,24 +652,26 @@ public sealed partial class BaseItemRepository
{
var maxScore = maxRating.Score;
var maxSubScore = maxRating.SubScore ?? 0;
- var linkedChildren = context.LinkedChildren;
+
+ // Only a manual link makes an item a container of other items.
+ var members = context.LinkedChildren
+ .Where(lc => lc.ChildType == Database.Implementations.Entities.LinkedChildType.Manual);
return e =>
- // Item has a rating: check against limit
- (e.InheritedParentalRatingValue != null
- && (e.InheritedParentalRatingValue < maxScore
- || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))
- // Item has no rating
- || (e.InheritedParentalRatingValue == null
- && (
- // No linked children (not a BoxSet/Playlist): pass as unrated
- !linkedChildren.Any(lc => lc.ParentId == e.Id)
- // Has linked children: at least one child must be within limits
- || linkedChildren.Any(lc => lc.ParentId == e.Id
- && (lc.Child!.InheritedParentalRatingValue == null
- || lc.Child.InheritedParentalRatingValue < maxScore
- || (lc.Child.InheritedParentalRatingValue == maxScore
- && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore)))));
+ // The item's own rating, where it has one, has to be within the limit. An unrated item
+ // passes here; blocking those is what BlockUnratedItems does.
+ (e.InheritedParentalRatingValue == null
+ || e.InheritedParentalRatingValue < maxScore
+ || (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))
+ // A container is only as visible as its members: a BoxSet or Playlist with nothing left
+ // in it for this user is hidden whatever rating it carries itself. BoxSet.IsVisible
+ // applies the same rule in memory, and a count has to agree with the listing it counts.
+ && (!members.Any(lc => lc.ParentId == e.Id)
+ || members.Any(lc => lc.ParentId == e.Id
+ && (lc.Child!.InheritedParentalRatingValue == null
+ || lc.Child.InheritedParentalRatingValue < maxScore
+ || (lc.Child.InheritedParentalRatingValue == maxScore
+ && (lc.Child.InheritedParentalRatingSubValue ?? 0) <= maxSubScore))));
}
/// <inheritdoc />
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index c9e08b1b5d..8d573569e9 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -593,10 +593,10 @@ public sealed partial class BaseItemRepository
return dbContext.BaseItems
.Where(e => descendantIds.Contains(e.Id) && !e.IsFolder && !e.IsVirtualItem)
- .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ .All(BuildLeafIsPlayedFilter(dbContext, user.Id));
}
- return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ return dbContext.BaseItems.Where(e => e.ParentId == id).All(BuildLeafIsPlayedFilter(dbContext, user.Id));
}
/// <inheritdoc />
@@ -626,18 +626,26 @@ public sealed partial class BaseItemRepository
.ToArray();
var tags = context.ItemValuesMap
- .Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags)
- .Where(ivm => matchingItemIds.Contains(ivm.ItemId))
- .Select(ivm => ivm.ItemValue)
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Tags)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(t => t)
.ToArray();
var genres = context.ItemValuesMap
- .Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre)
- .Where(ivm => matchingItemIds.Contains(ivm.ItemId))
- .Select(ivm => ivm.ItemValue)
+ .Join(
+ context.ItemValues,
+ ivm => ivm.ItemValueId,
+ iv => iv.ItemValueId,
+ (ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
+ .Where(iv => iv.Type == ItemValueType.Genre)
+ .Where(iv => matchingItemIds.Contains(iv.ItemId))
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(g => g)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index d635b38df5..d726f0f143 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -38,22 +38,32 @@ public sealed partial class BaseItemRepository
// Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree.
private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user)
{
- var userId = user.Id;
+ // Folders (Series, Seasons, BoxSets, albums, ...) carry no played state of their own and count
+ // as played once no descendant is left unplayed.
+ var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
+ .Where(BuildLeafIsPlayedFilter(context, user.Id).Not());
+
+ return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
+ .Or(IsFolderFilter.Not().And(BuildLeafIsPlayedFilter(context, user.Id)));
+ }
- // Leaf items carry their own played state.
+ private static Expression<Func<BaseItemEntity, bool>> BuildLeafIsPlayedFilter(JellyfinDbContext context, Guid userId)
+ {
var playedItemIds = context.UserData
.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => ud.ItemId);
- // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
- // descendant is left unplayed, matching what the DTO reports for them. This has to key off
- // the item itself rather than off the requested item types: tag and collection listings mix
- // folders and leaf items in a single query.
- var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
- .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ // The primaries of every version group holding a played row, whichever version carries it.
+ var playedGroupIds = context.BaseItems
+ .Where(v => v.PrimaryVersionId != null
+ && context.UserData.Any(ud => ud.UserId == userId
+ && ud.Played
+ && (ud.ItemId == v.Id || ud.ItemId == v.PrimaryVersionId)))
+ .Select(v => v.PrimaryVersionId!.Value);
- return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
- .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
+ return e => playedItemIds.Contains(e.Id)
+ || playedGroupIds.Contains(e.Id)
+ || (e.PrimaryVersionId != null && playedGroupIds.Contains(e.PrimaryVersionId.Value));
}
// "und" is the language filters' stand-in for a track that declares no language at all.
@@ -571,8 +581,8 @@ public sealed partial class BaseItemRepository
.ToArray();
var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type))
.And(BuildHasDescendantFilter(context, inProgressLeafItems)
- .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
- .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))))));
+ .Or(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId)))
+ .And(BuildHasDescendantFilter(context, leafItems.Where(BuildLeafIsPlayedFilter(context, userId).Not())))));
if (isResumable)
{
@@ -797,11 +807,16 @@ public sealed partial class BaseItemRepository
{
// Exclude owned non-extra items from general queries.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
- // 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));
+ baseQuery = baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null);
+
+ // Alternate versions (PrimaryVersionId set) are normally hidden behind their primary, but
+ // resume queries keep them so the actually-played version can surface instead of collapsing
+ // onto the primary, and the library scan keeps them so a merged version is not mistaken for
+ // a new item.
+ if (filter.IsResumable != true && !filter.IncludeAlternateVersions)
+ {
+ baseQuery = ApplyAlternateVersionFiltering(context, baseQuery);
+ }
}
if (filter.OwnerIds.Length > 0)
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs
new file mode 100644
index 0000000000..0f8a1b9dfe
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Item/ItemCountBuilder.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Dto;
+
+namespace Jellyfin.Server.Implementations.Item;
+
+/// <summary>
+/// Turns per-type counts into an <see cref="ItemCounts"/>.
+/// </summary>
+internal static class ItemCountBuilder
+{
+ /// <summary>
+ /// Builds the counts of one by-name item.
+ /// </summary>
+ /// <param name="itemTypeLookup">The item type lookup.</param>
+ /// <param name="counts">The counted items, by type name. A type may repeat.</param>
+ /// <returns>The counts.</returns>
+ public static ItemCounts Build(IItemTypeLookup itemTypeLookup, IEnumerable<(string Type, int Count)> counts)
+ {
+ ArgumentNullException.ThrowIfNull(itemTypeLookup);
+ ArgumentNullException.ThrowIfNull(counts);
+
+ var lookup = itemTypeLookup.BaseItemKindNames;
+ var result = new ItemCounts();
+
+ foreach (var (type, count) in counts)
+ {
+ // Accumulated rather than assigned: a caller may group by something finer than the
+ // type and hand the same type over more than once.
+ if (string.Equals(type, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal))
+ {
+ result.AlbumCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal))
+ {
+ result.ArtistCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Episode], StringComparison.Ordinal))
+ {
+ result.EpisodeCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Movie], StringComparison.Ordinal))
+ {
+ result.MovieCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal))
+ {
+ result.MusicVideoCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal))
+ {
+ result.ProgramCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Series], StringComparison.Ordinal))
+ {
+ result.SeriesCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Audio], StringComparison.Ordinal))
+ {
+ result.SongCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Trailer], StringComparison.Ordinal))
+ {
+ result.TrailerCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal))
+ {
+ result.BoxSetCount += count;
+ }
+ else if (string.Equals(type, lookup[BaseItemKind.Book], StringComparison.Ordinal))
+ {
+ result.BookCount += count;
+ }
+ }
+
+ result.ItemCount = result.TotalItemCount();
+
+ return result;
+ }
+
+ /// <summary>
+ /// Replaces the episode count, which both by-name paths decide separately from the other
+ /// types because a genre or studio is usually written on the series rather than its episodes.
+ /// </summary>
+ /// <param name="counts">The counts to update.</param>
+ /// <param name="episodeCount">The episode count.</param>
+ public static void SetEpisodeCount(ItemCounts counts, int episodeCount)
+ {
+ ArgumentNullException.ThrowIfNull(counts);
+
+ counts.EpisodeCount = episodeCount;
+ counts.ItemCount = counts.TotalItemCount();
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
index 704dc31fd0..942161a176 100644
--- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs
@@ -7,6 +7,7 @@ using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Persistence;
@@ -125,178 +126,286 @@ public class ItemCountService : IItemCountService
/// <inheritdoc />
public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter)
{
- using var context = _dbProvider.CreateDbContext();
+ return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id];
+ }
- var item = context.BaseItems.AsNoTracking()
- .Where(e => e.Id == id)
- .Select(e => new { e.Name, e.CleanName })
- .FirstOrDefault();
+ private static ItemValueType[] GetItemValueTypes(BaseItemKind kind)
+ => kind switch
+ {
+ BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist],
+ BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre],
+ BaseItemKind.Studio => [ItemValueType.Studios],
+ _ => []
+ };
- if (item is null)
+ /// <inheritdoc />
+ public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter)
+ {
+ ArgumentNullException.ThrowIfNull(ids);
+ ArgumentNullException.ThrowIfNull(relatedItemKinds);
+ ArgumentNullException.ThrowIfNull(accessFilter);
+
+ var result = new Dictionary<Guid, ItemCounts>();
+ if (ids.Count == 0)
{
- return new ItemCounts();
+ return result;
}
- IQueryable<BaseItemEntity> baseQuery;
- switch (kind)
+ using var context = _dbProvider.CreateDbContext();
+
+ var idsArray = ids as Guid[] ?? ids.ToArray();
+ var nameItems = context.BaseItems.AsNoTracking()
+ .WhereOneOrMany(idsArray, e => e.Id)
+ .Select(e => new NameItem(e.Id, e.Name, e.CleanName))
+ .ToArray();
+
+ foreach (var id in ids)
{
- case BaseItemKind.Person:
- baseQuery = ItemsById(context, context.PeopleBaseItemMap
- .AsNoTracking()
- .Where(m => m.People.Name == item.Name)
- .Select(m => m.ItemId));
- break;
- case BaseItemKind.MusicArtist:
- baseQuery = ItemsById(context, context.ItemValuesMap
- .AsNoTracking()
- .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
- && (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist))
- .Select(ivm => ivm.ItemId));
- break;
- case BaseItemKind.Genre:
- case BaseItemKind.MusicGenre:
- baseQuery = ItemsById(context, context.ItemValuesMap
- .AsNoTracking()
- .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
- && ivm.ItemValue.Type == ItemValueType.Genre)
- .Select(ivm => ivm.ItemId));
- break;
- case BaseItemKind.Studio:
- baseQuery = ItemsById(context, context.ItemValuesMap
- .AsNoTracking()
- .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
- && ivm.ItemValue.Type == ItemValueType.Studios)
- .Select(ivm => ivm.ItemId));
- break;
- case BaseItemKind.Year:
- if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
- {
- baseQuery = context.BaseItems
- .AsNoTracking()
- .Where(e => e.ProductionYear == year);
- }
- else
- {
- return new ItemCounts();
- }
+ result[id] = new ItemCounts();
+ }
- break;
- default:
- return new ItemCounts();
+ if (nameItems.Length == 0)
+ {
+ return result;
}
var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray();
- baseQuery = baseQuery.Where(e => typeNames.Contains(e.Type));
+ var related = _queryHelpers.ApplyAccessFiltering(
+ context,
+ context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)),
+ accessFilter);
- baseQuery = _queryHelpers.ApplyAccessFiltering(context, baseQuery, accessFilter);
+ var valueTypes = GetItemValueTypes(kind);
+ if (valueTypes.Length > 0)
+ {
+ CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result);
+ }
+ else if (kind == BaseItemKind.Person)
+ {
+ CountByPersonName(context, related, nameItems, result);
+ }
+ else if (kind == BaseItemKind.Year)
+ {
+ CountByProductionYear(related, nameItems, result);
+ }
- var counts = baseQuery
- .GroupBy(x => x.Type)
- .Select(x => new { x.Key, Count = x.Count() })
- .ToArray();
+ return result;
+ }
- var lookup = _itemTypeLookup.BaseItemKindNames;
- var result = new ItemCounts();
- var totalCount = 0;
+ private void CountByItemValue(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> related,
+ BaseItemKind kind,
+ BaseItemKind[] relatedItemKinds,
+ ItemValueType[] valueTypes,
+ NameItem[] nameItems,
+ Dictionary<Guid, ItemCounts> result)
+ {
+ var cleanNames = nameItems
+ .Select(n => n.CleanName)
+ .OfType<string>()
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
- foreach (var count in counts)
+ if (cleanNames.Length == 0)
{
- totalCount += count.Count;
+ return;
+ }
- if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal))
- {
- result.AlbumCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal))
- {
- result.ArtistCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal))
- {
- result.EpisodeCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal))
- {
- result.MovieCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal))
- {
- result.MusicVideoCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal))
- {
- result.ProgramCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal))
- {
- result.SeriesCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal))
- {
- result.SongCount = count.Count;
- }
- else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal))
+ var grouped = context.ItemValuesMap.AsNoTracking()
+ .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type))
+ .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue)
+ .Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type, e.Id })
+ .GroupBy(x => new { x.CleanValue, x.Type })
+ .Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() })
+ .ToArray();
+
+ var byCleanName = grouped
+ .GroupBy(g => g.CleanValue, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal);
+
+ var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
+ var episodeRollUp = RollsUpEpisodes(kind, relatedItemKinds)
+ && Array.Exists(grouped, g => string.Equals(g.Type, seriesTypeName, StringComparison.Ordinal))
+ ? CountEpisodesOfTaggedSeriesByCleanName(context, related, valueTypes, cleanNames)
+ : null;
+
+ foreach (var nameItem in nameItems)
+ {
+ if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts))
{
- result.TrailerCount = count.Count;
+ continue;
}
- else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal))
+
+ var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts);
+
+ if (episodeRollUp is not null)
{
- result.BoxSetCount = count.Count;
+ var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName);
+
+ // Episodes of a tagged series count towards it even when untagged themselves, and
+ // a tagged episode of a tagged series must not be counted a second time.
+ var directEpisodeCount = itemCounts.EpisodeCount - rollUp.TaggedEpisodesOfTaggedSeries;
+ ItemCountBuilder.SetEpisodeCount(itemCounts, rollUp.EpisodesOfTaggedSeries + directEpisodeCount);
}
- else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal))
+
+ result[nameItem.Id] = itemCounts;
+ }
+ }
+
+ private void CountByPersonName(
+ JellyfinDbContext context,
+ IQueryable<BaseItemEntity> related,
+ NameItem[] nameItems,
+ Dictionary<Guid, ItemCounts> result)
+ {
+ var names = nameItems
+ .Select(n => n.Name)
+ .OfType<string>()
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+
+ if (names.Length == 0)
+ {
+ return;
+ }
+
+ var grouped = context.PeopleBaseItemMap.AsNoTracking()
+ .WhereOneOrMany(names, m => m.People.Name)
+ .Join(related, m => m.ItemId, e => e.Id, (m, e) => new { m.People.Name, e.Type, e.Id })
+ .GroupBy(x => new { x.Name, x.Type })
+ // A person can be credited on one item more than once, in different roles.
+ .Select(g => new { g.Key.Name, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() })
+ .ToArray();
+
+ ApplyGroupedCounts(nameItems, n => n.Name, grouped.Select(g => (g.Name, g.Type, g.Count)), result);
+ }
+
+ private void CountByProductionYear(
+ IQueryable<BaseItemEntity> related,
+ NameItem[] nameItems,
+ Dictionary<Guid, ItemCounts> result)
+ {
+ var years = new List<int>();
+ foreach (var nameItem in nameItems)
+ {
+ if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)
+ && !years.Contains(year))
{
- result.BookCount = count.Count;
+ years.Add(year);
}
}
- if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
- && relatedItemKinds.Contains(BaseItemKind.Episode)
- && relatedItemKinds.Contains(BaseItemKind.Series))
+ if (years.Count == 0)
{
- var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount);
- totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount;
- result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount;
+ return;
}
- result.ItemCount = totalCount;
+ // No join, so no row can be reached twice and a plain count is the distinct count.
+ var grouped = related
+ .Where(e => e.ProductionYear != null)
+ .WhereOneOrMany(years, e => e.ProductionYear!.Value)
+ .GroupBy(e => new { Year = e.ProductionYear!.Value, e.Type })
+ .Select(g => new { g.Key.Year, g.Key.Type, Count = g.Count() })
+ .ToArray();
- return result;
+ var byYear = grouped
+ .GroupBy(g => g.Year)
+ .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray());
+
+ foreach (var nameItem in nameItems)
+ {
+ if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)
+ && byYear.TryGetValue(year, out var counts))
+ {
+ result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts);
+ }
+ }
}
- private int CountEpisodesOfTaggedSeries(
+ private void ApplyGroupedCounts(
+ NameItem[] nameItems,
+ Func<NameItem, string?> keySelector,
+ IEnumerable<(string Key, string Type, int Count)> grouped,
+ Dictionary<Guid, ItemCounts> result)
+ {
+ var byKey = grouped
+ .GroupBy(g => g.Key, StringComparer.Ordinal)
+ .ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal);
+
+ foreach (var nameItem in nameItems)
+ {
+ var key = keySelector(nameItem);
+ if (key is not null && byKey.TryGetValue(key, out var counts))
+ {
+ result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts);
+ }
+ }
+ }
+
+ private static bool RollsUpEpisodes(BaseItemKind kind, BaseItemKind[] relatedItemKinds)
+ => kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
+ && relatedItemKinds.Contains(BaseItemKind.Episode)
+ && relatedItemKinds.Contains(BaseItemKind.Series);
+
+ private Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)> CountEpisodesOfTaggedSeriesByCleanName(
JellyfinDbContext context,
- IQueryable<BaseItemEntity> taggedItems,
- InternalItemsQuery accessFilter,
- out int unrelatedEpisodeCount)
+ IQueryable<BaseItemEntity> related,
+ ItemValueType[] valueTypes,
+ string[] cleanNames)
{
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
- var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id);
- unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName
- && (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value)));
+ var taggedValues = context.ItemValuesMap.AsNoTracking()
+ .Where(ivm => valueTypes.Contains(ivm.ItemValue.Type))
+ .WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
+
+ // The series carrying each clean name. Distinct, because one item can be mapped to the
+ // same clean name once per value type.
+ var taggedSeries = taggedValues
+ .Join(
+ related.Where(e => e.Type == seriesTypeName),
+ ivm => ivm.ItemId,
+ e => e.Id,
+ (ivm, e) => new { ivm.ItemValue.CleanValue, SeriesId = e.Id })
+ .Distinct();
+
+ var episodes = related.Where(e => e.Type == episodeTypeName && e.SeriesId != null);
+
+ var episodesOfTaggedSeries = taggedSeries
+ .Join(episodes, s => s.SeriesId, e => e.SeriesId!.Value, (s, e) => new { s.CleanValue, e.Id })
+ .GroupBy(x => x.CleanValue)
+ .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() })
+ .ToArray();
- // Materialised so the episode count drives off IX_BaseItems_SeriesId.
- var seriesIds = taggedItems
- .Where(e => e.Type == seriesTypeName)
- .Select(e => e.Id)
+ // Episodes that carry the clean name themselves *and* belong to a series carrying it. The
+ // roll-up already counts those, so they have to come off the directly tagged ones.
+ var taggedEpisodesOfTaggedSeries = taggedValues
+ .Join(episodes, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Id, e.SeriesId })
+ .Join(
+ taggedSeries,
+ e => new { e.CleanValue, SeriesId = e.SeriesId!.Value },
+ s => new { s.CleanValue, s.SeriesId },
+ (e, s) => new { e.CleanValue, e.Id })
+ .GroupBy(x => x.CleanValue)
+ .Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() })
.ToArray();
- if (seriesIds.Length == 0)
+ var taggedLookup = taggedEpisodesOfTaggedSeries
+ .ToDictionary(x => x.CleanValue, x => x.Count, StringComparer.Ordinal);
+
+ // Every clean name in taggedLookup came from an episode of a tagged series, so it always
+ // has a row in episodesOfTaggedSeries too - no second merge pass is needed.
+ var result = new Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)>(StringComparer.Ordinal);
+ foreach (var entry in episodesOfTaggedSeries)
{
- return 0;
+ result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue));
}
- var episodes = context.BaseItems.AsNoTracking()
- .Where(e => e.Type == episodeTypeName && e.SeriesId != null)
- .WhereOneOrMany(seriesIds, e => e.SeriesId!.Value);
-
- return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count();
+ return result;
}
- private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
- => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
-
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
@@ -305,7 +414,7 @@ public class ItemCountService : IItemCountService
using var dbContext = _dbProvider.CreateDbContext();
var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId);
- return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played));
+ return baseQuery.Count(DescendantQueryHelper.IsPlayedBy(filter.User.Id));
}
/// <inheritdoc/>
@@ -374,7 +483,19 @@ public class ItemCountService : IItemCountService
var includeVirtual = user is null || user.DisplayMissingEpisodes;
- var hierarchicalCounts = dbContext.BaseItems
+ var accessibleItems = dbContext.BaseItems.AsNoTracking();
+ if (user is null)
+ {
+ // Access filtering is what would otherwise drop an alternate version, and a child count
+ // must not report a title twice just because no user was passed in.
+ accessibleItems = accessibleItems.Where(DescendantQueryHelper.IsDistinctLibraryItem);
+ }
+ else
+ {
+ accessibleItems = _queryHelpers.ApplyAccessFiltering(dbContext, accessibleItems, new InternalItemsQuery(user));
+ }
+
+ var hierarchicalCounts = accessibleItems
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(parentIdsArray, b => b.ParentId!.Value)
.GroupBy(b => b.ParentId!.Value)
@@ -384,20 +505,22 @@ public class ItemCountService : IItemCountService
// An episode is a child of its season even when it is not stored under one: with a flat
// structure ParentId points at the series, so counting by ParentId alone leaves the season
// empty and counts its episodes towards the series instead.
- var seasonCounts = dbContext.BaseItems
+ var seasonCounts = accessibleItems
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(parentIdsArray, b => b.SeasonId!.Value)
.GroupBy(b => b.SeasonId!.Value)
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
.ToDictionary(x => x.SeasonId, x => x.Count);
+ // A linked child counts only when the item it points at is one the user may open.
var linkedCounts = dbContext.LinkedChildren
.WhereOneOrMany(parentIdsArray, lc => lc.ParentId)
- .GroupBy(lc => lc.ParentId)
+ .Join(accessibleItems, lc => lc.ChildId, b => b.Id, (lc, b) => lc.ParentId)
+ .GroupBy(parentId => parentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(x => x.ParentId, x => x.Count);
- var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray, includeVirtual);
+ var mergedChildCounts = GetMergedChildCounts(dbContext, accessibleItems, parentIdsArray, includeVirtual);
var result = new Dictionary<Guid, int>();
foreach (var parentId in parentIds)
@@ -418,7 +541,11 @@ public class ItemCountService : IItemCountService
return result;
}
- private static Dictionary<Guid, int> GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList<Guid> parentIds, bool includeVirtual)
+ private static Dictionary<Guid, int> GetMergedChildCounts(
+ JellyfinDbContext dbContext,
+ IQueryable<BaseItemEntity> accessibleItems,
+ IReadOnlyList<Guid> parentIds,
+ bool includeVirtual)
{
var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds)
.Where(group => group.Value.Count > 1)
@@ -431,14 +558,12 @@ public class ItemCountService : IItemCountService
// Only merged folders.
var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray();
- var children = dbContext.BaseItems
- .AsNoTracking()
+ var children = accessibleItems
.Where(b => b.ParentId.HasValue && !b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(memberIds, b => b.ParentId!.Value)
.Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey })
.ToArray()
- .Concat(dbContext.BaseItems
- .AsNoTracking()
+ .Concat(accessibleItems
.Where(b => b.SeasonId.HasValue && (includeVirtual || !b.IsVirtualItem))
.WhereOneOrMany(memberIds, b => b.SeasonId!.Value)
.Select(b => new { ParentId = b.SeasonId!.Value, b.Id, b.PresentationUniqueKey })
@@ -492,7 +617,7 @@ public class ItemCountService : IItemCountService
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
var playedLeafItems = leafItems
- .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
+ .Select(DescendantQueryHelper.PlayedStateBy(userId));
var ancestorLeaves = dbContext.AncestorIds
.WhereOneOrMany(folderIdsArray, a => a.ParentItemId)
@@ -610,7 +735,7 @@ public class ItemCountService : IItemCountService
private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable<BaseItemEntity> query, Guid userId)
{
var result = query
- .Select(b => b.UserData!.Any(u => u.UserId == userId && u.Played))
+ .Select(DescendantQueryHelper.IsPlayedBy(userId))
.GroupBy(_ => 1)
.OrderBy(g => g.Key)
.Select(g => new
@@ -622,4 +747,12 @@ public class ItemCountService : IItemCountService
return result is null ? (0, 0) : (result.Played, result.Total);
}
+
+ /// <summary>
+ /// A by-name item, reduced to the three columns the counting keys off.
+ /// </summary>
+ /// <param name="Id">The id of the by-name item.</param>
+ /// <param name="Name">The name of the by-name item.</param>
+ /// <param name="CleanName">The cleaned name of the by-name item.</param>
+ private sealed record NameItem(Guid Id, string? Name, string? CleanName);
}
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index efff3457a3..a7b0b1c1fc 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -68,16 +69,24 @@ public class ItemPersistenceService : IItemPersistenceService
// 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)
- .WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
- .Select(e => e.Id)
- .ToArray();
-
- foreach (var extraId in extraIds)
+ var frontier = descendantIds.ToArray();
+ while (frontier.Length > 0)
{
- descendantIds.Add(extraId);
+ var ownedIds = context.BaseItems
+ .Where(e => e.OwnerId.HasValue)
+ .WhereOneOrMany(frontier, e => e.OwnerId!.Value)
+ .Select(e => e.Id)
+ .ToArray();
+
+ var childIds = context.BaseItems
+ .Where(e => e.ParentId.HasValue)
+ .WhereOneOrMany(frontier, e => e.ParentId!.Value)
+ .Select(e => e.Id)
+ .ToArray();
+
+ // Only ids that were not already known become the next frontier, so ownership cycles
+ // terminate instead of looping forever.
+ frontier = [.. ownedIds.Concat(childIds).Where(e => descendantIds.Add(e))];
}
var relatedItems = descendantIds.ToArray();
@@ -136,13 +145,13 @@ public class ItemPersistenceService : IItemPersistenceService
context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ParentId).ExecuteDelete();
context.LinkedChildren.WhereOneOrMany(relatedItems, e => e.ChildId).ExecuteDelete();
+ var peopleIds = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray();
context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
context.KeyframeData.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.MediaSegments.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.MediaStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
- var query = context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).Select(f => f.PeopleId).Distinct().ToArray();
context.PeopleBaseItemMap.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
- context.Peoples.WhereOneOrMany(query, e => e.Id).Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
+ context.Peoples.WhereOneOrMany(peopleIds, e => e.Id).Where(e => !e.BaseItems!.Any()).ExecuteDelete();
context.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.SaveChanges();
transaction.Commit();
@@ -176,14 +185,6 @@ public class ItemPersistenceService : IItemPersistenceService
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
- if (!await context.BaseItems
- .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
- .ConfigureAwait(false))
- {
- _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
- return;
- }
-
await context.BaseItemImageInfos
.Where(e => e.ItemId == item.Id)
.ExecuteDeleteAsync(cancellationToken)
@@ -193,7 +194,26 @@ public class ItemPersistenceService : IItemPersistenceService
.AddRangeAsync(images, cancellationToken)
.ConfigureAwait(false);
- await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (DbUpdateException)
+ {
+ // Checking that the item exists before writing leaves a gap a scan can delete it
+ // through, turning the insert into a foreign key violation that fails the whole
+ // refresh instead of the no-op intended here. Let the insert be the check: it is the
+ // only point at which the answer cannot go stale. Nothing is orphaned by the delete
+ // above, because deleting the item cascades to its images anyway.
+ if (await context.BaseItems
+ .AnyAsync(bi => bi.Id == item.Id, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ throw;
+ }
+
+ _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id);
+ }
}
}
@@ -257,7 +277,7 @@ public class ItemPersistenceService : IItemPersistenceService
using var transaction = context.Database.BeginTransaction();
var ids = tuples.Select(f => f.Item.Id).ToArray();
- var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet();
+ var existingItems = context.BaseItems.WhereOneOrMany(ids, e => e.Id).Select(f => f.Id).ToHashSet();
foreach (var item in tuples)
{
@@ -317,7 +337,7 @@ public class ItemPersistenceService : IItemPersistenceService
.Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray()))
.ToArray();
- var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
+ var mappedValues = context.ItemValuesMap.WhereOneOrMany(ids, e => e.ItemId).ToList();
foreach (var item in valueMap)
{
@@ -646,6 +666,38 @@ public class ItemPersistenceService : IItemPersistenceService
sortOrder++;
}
+ var linkedChildIds = newLinkedChildren
+ .Select(c => c.ChildId)
+ // A video listed among its own versions would be pointed at itself.
+ .Where(childId => existingChildIds.Contains(childId) && !childId.Equals(video.Id))
+ .Where(childId => !childId.Equals(video.PrimaryVersionId))
+ .ToList();
+ if (linkedChildIds.Count > 0)
+ {
+ var demotedChildren = context.BaseItems
+ .Where(e => linkedChildIds.Contains(e.Id)
+ && (e.PrimaryVersionId == null || e.PrimaryVersionId != video.Id))
+ .ToList();
+
+ foreach (var child in demotedChildren)
+ {
+ child.PrimaryVersionId = video.Id;
+
+ // Mirrors Video.CreatePresentationUniqueKey, so presentation-key grouping
+ // collapses the version onto its primary as well.
+ child.PresentationUniqueKey = video.Id.ToString("N", CultureInfo.InvariantCulture);
+ }
+
+ if (demotedChildren.Count > 0)
+ {
+ _logger.LogInformation(
+ "Set PrimaryVersionId on {Count} alternate versions of video {VideoName} ({VideoId})",
+ demotedChildren.Count,
+ video.Name,
+ video.Id);
+ }
+ }
+
// A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
if (previousLinkedChildren is { Count: > 0 })
diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs
index f478daef23..897fb98cbb 100644
--- a/Jellyfin.Server.Implementations/Item/NextUpService.cs
+++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs
@@ -95,7 +95,7 @@ public class NextUpService : INextUpService
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
- .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsPlayedBy(userId));
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
// Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
@@ -129,12 +129,21 @@ public class NextUpService : INextUpService
// Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation.
// SelectMany on UserData with a correlated Where would translate to APPLY,
// which SQLite does not support.
+ // Access filtering leaves only primaries in the base query, but a play can be recorded
+ // against any version, so each row is attributed to its group's primary before the join.
+ var playedByGroupPrimary = context.UserData
+ .AsNoTracking()
+ .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
+ .Where(ud => ud.Played)
+ .Join(
+ context.BaseItems.AsNoTracking(),
+ ud => ud.ItemId,
+ bi => bi.Id,
+ (ud, bi) => new { ud.UserId, ItemId = bi.PrimaryVersionId ?? bi.Id, ud.LastPlayedDate });
+
var playedWithDates = lastWatchedByDateBase
.Join(
- context.UserData
- .AsNoTracking()
- .Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
- .Where(ud => ud.Played),
+ playedByGroupPrimary,
e => new { UserId = userId, ItemId = e.Id },
ud => new { ud.UserId, ud.ItemId },
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
@@ -198,7 +207,7 @@ public class NextUpService : INextUpService
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
- .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsUnplayedBy(userId));
allUnplayedBase = _queryHelpers.ApplyAccessFiltering(context, allUnplayedBase, filter);
var allUnplayedCandidates = allUnplayedBase
.Select(e => new
@@ -246,7 +255,7 @@ public class NextUpService : INextUpService
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => !e.IsVirtualItem)
- .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
+ .Where(DescendantQueryHelper.IsPlayedBy(userId));
allPlayedBase = _queryHelpers.ApplyAccessFiltering(context, allPlayedBase, filter);
var allPlayedCandidates = allPlayedBase
.Select(e => new
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index da2ad033ec..fcddc09ad9 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -127,18 +127,61 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray();
var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray();
- var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray();
using var context = _dbProvider.CreateDbContext();
+ var existingMaps = context.PeopleBaseItemMap
+ .AsNoTracking()
+ .Include(e => e.People)
+ .Where(e => e.ItemId == itemId)
+ .ToList();
+
+ // Most library scans refresh unchanged local metadata. Avoid opening a write
+ // transaction when the item's people mappings, order and roles are unchanged.
+ var incomingCredits = distinctCredits
+ .Select((credit, index) => new
+ {
+ Key = (credit.LoweredName, credit.PersonType, credit.LoweredRole),
+ Role = credit.Person.Role,
+ ListOrder = index,
+ SortOrder = credit.Person.SortOrder
+ })
+ .ToDictionary(e => e.Key);
+ var mappingsAreUnchanged = existingMaps.Count == incomingCredits.Count
+ && existingMaps.All(map =>
+ incomingCredits.TryGetValue(
+ (map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty),
+ out var incoming)
+ && map.ListOrder == incoming.ListOrder
+ && map.SortOrder == incoming.SortOrder
+ && string.Equals(map.Role ?? string.Empty, incoming.Role, StringComparison.OrdinalIgnoreCase));
+
+ if (mappingsAreUnchanged)
+ {
+ return;
+ }
+
using var transaction = context.Database.BeginTransaction();
- var existingPersons = context.Peoples.Select(e => new
+ // The fast-path snapshot was read before acquiring the write transaction. Reload
+ // tracked mappings inside it so a concurrent refresh cannot leave stale credits.
+ existingMaps = context.PeopleBaseItemMap
+ .Include(e => e.People)
+ .Where(e => e.ItemId == itemId)
+ .ToList();
+
+ // Query each person type separately so SQLite can use IX_Peoples_NameLower.
+ // Combining the two fields into `lower(Name) || '-' || PersonType` forces a full
+ // scan of Peoples for every media item, which is prohibitive during a large import.
+ var existingPersons = new List<People>();
+ foreach (var personTypeGroup in distinctPersons.GroupBy(e => e.PersonType, StringComparer.Ordinal))
{
- item = e,
- SelectionKey = e.Name.ToLower() + "-" + e.PersonType
- })
- .Where(p => personKeys.Contains(p.SelectionKey))
- .Select(f => f.item)
- .ToArray();
+ var names = personTypeGroup
+ .Select(e => e.LoweredName)
+ .ToArray();
+
+ existingPersons.AddRange(context.Peoples
+ .Where(e => e.PersonType == personTypeGroup.Key && names.Contains(e.Name.ToLower()))
+ .ToArray());
+ }
var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
@@ -157,7 +200,6 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity);
}
- var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>();
foreach (var map in existingMaps)
{
@@ -238,7 +280,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
using var context = _dbProvider.CreateDbContext();
var query = context.PeopleBaseItemMap
.AsNoTracking()
- .Where(m => itemIds.Contains(m.ItemId));
+ .WhereOneOrMany(itemIds, m => m.ItemId);
if (personTypes.Count > 0)
{
@@ -274,7 +316,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
using var context = _dbProvider.CreateDbContext();
var rows = context.PeopleBaseItemMap
.AsNoTracking()
- .Where(m => itemIds.Contains(m.ItemId))
+ .WhereOneOrMany(itemIds, m => m.ItemId)
.OrderBy(m => m.ListOrder)
.Select(m => new
{
diff --git a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs
index 92e2bb4fa7..7c46ef7721 100644
--- a/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs
+++ b/Jellyfin.Server.Implementations/Users/DeviceAccessHost.cs
@@ -1,3 +1,4 @@
+using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data;
@@ -9,6 +10,7 @@ using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Implementations.Users;
@@ -20,6 +22,7 @@ public sealed class DeviceAccessHost : IHostedService
private readonly IUserManager _userManager;
private readonly IDeviceManager _deviceManager;
private readonly ISessionManager _sessionManager;
+ private readonly ILogger<DeviceAccessHost> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DeviceAccessHost"/> class.
@@ -27,11 +30,17 @@ public sealed class DeviceAccessHost : IHostedService
/// <param name="userManager">The <see cref="IUserManager"/>.</param>
/// <param name="deviceManager">The <see cref="IDeviceManager"/>.</param>
/// <param name="sessionManager">The <see cref="ISessionManager"/>.</param>
- public DeviceAccessHost(IUserManager userManager, IDeviceManager deviceManager, ISessionManager sessionManager)
+ /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
+ public DeviceAccessHost(
+ IUserManager userManager,
+ IDeviceManager deviceManager,
+ ISessionManager sessionManager,
+ ILogger<DeviceAccessHost> logger)
{
_userManager = userManager;
_deviceManager = deviceManager;
_sessionManager = sessionManager;
+ _logger = logger;
}
/// <inheritdoc />
@@ -53,9 +62,18 @@ public sealed class DeviceAccessHost : IHostedService
private async void OnUserUpdated(object? sender, GenericEventArgs<User> e)
{
var user = e.Argument;
- if (!user.HasPermission(PermissionKind.EnableAllDevices))
+
+ // This handler is async void, so an escaping exception would terminate the process.
+ try
+ {
+ if (!user.HasPermission(PermissionKind.EnableAllDevices))
+ {
+ await UpdateDeviceAccess(user).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
{
- await UpdateDeviceAccess(user).ConfigureAwait(false);
+ _logger.LogError(ex, "Error updating device access for user {UserId}", user.Id);
}
}
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index fea6084267..b15f6b98b2 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -847,14 +847,16 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
{
+ User user;
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- var user = UserQuery(dbContext)
+ user = await UserQuery(dbContext)
.AsTracking()
- .FirstOrDefault(u => u.Id.Equals(userId))
+ .FirstOrDefaultAsync(u => u.Id.Equals(userId))
+ .ConfigureAwait(false)
?? throw new ArgumentException("No user exists with given Id!");
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
@@ -919,6 +921,10 @@ namespace Jellyfin.Server.Implementations.Users
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
+
+ var eventArgs = new UserUpdatedEventArgs(user);
+ await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
+ OnUserUpdated?.Invoke(this, eventArgs);
}
/// <inheritdoc/>
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
index beafc3916f..a5b6bc4604 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -183,7 +183,13 @@ internal class JellyfinMigrationService
}
}
- public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
+ /// <summary>
+ /// Runs all pending migrations of the requested stage.
+ /// </summary>
+ /// <param name="stage">The stage to migrate.</param>
+ /// <param name="serviceProvider">The service provider handed to the migrations.</param>
+ /// <returns>A value indicating whether at least one migration has been applied.</returns>
+ public async Task<bool> MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider serviceProvider)
{
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? [];
@@ -297,6 +303,8 @@ internal class JellyfinMigrationService
completedMigrations++;
}
+
+ return completedMigrations > 0;
}
}
@@ -445,10 +453,10 @@ internal class JellyfinMigrationService
private class InternalCodeMigration : IInternalMigration
{
private readonly CodeMigration _codeMigration;
- private readonly IServiceProvider? _serviceProvider;
+ private readonly IServiceProvider _serviceProvider;
private JellyfinDbContext _dbContext;
- public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider? serviceProvider, JellyfinDbContext dbContext)
+ public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider serviceProvider, JellyfinDbContext dbContext)
{
_codeMigration = codeMigration;
_serviceProvider = serviceProvider;
diff --git a/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs
index 8a0a1741f1..291de23b2e 100644
--- a/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs
+++ b/Jellyfin.Server/Migrations/Routines/20250618010000_MigrateLibraryUserData.cs
@@ -29,7 +29,7 @@ internal class MigrateLibraryUserData : IAsyncMigrationRoutine
private readonly IDbContextFactory<JellyfinDbContext> _provider;
public MigrateLibraryUserData(
- IStartupLogger<MigrateLibraryDb> startupLogger,
+ IStartupLogger<MigrateLibraryUserData> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths)
{
diff --git a/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs
index 502763ac09..c8ee44a670 100644
--- a/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs
+++ b/Jellyfin.Server/Migrations/Routines/20250730215000_ReseedFolderFlag.cs
@@ -24,7 +24,7 @@ internal class ReseedFolderFlag : IAsyncMigrationRoutine
private readonly IDbContextFactory<JellyfinDbContext> _provider;
public ReseedFolderFlag(
- IStartupLogger<MigrateLibraryDb> startupLogger,
+ IStartupLogger<ReseedFolderFlag> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths)
{
diff --git a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs
index 3fc2387e09..8eefbdb63a 100644
--- a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs
@@ -156,6 +156,7 @@ internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine
await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false);
await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false);
+ await MoveRemainingReferencesAsync(dbContext, newParentId, staleIds, cancellationToken).ConfigureAwait(false);
// Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last.
await dbContext.BaseItems
@@ -171,6 +172,31 @@ internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine
canonicalId);
}
+ private static async Task MoveRemainingReferencesAsync(
+ JellyfinDbContext dbContext,
+ Guid? canonicalId,
+ IReadOnlyList<Guid> staleIds,
+ CancellationToken cancellationToken)
+ {
+ await dbContext.BaseItems
+ .Where(e => e.OwnerId.HasValue)
+ .WhereOneOrMany(staleIds, e => e.OwnerId!.Value)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.OwnerId, canonicalId), cancellationToken)
+ .ConfigureAwait(false);
+
+ // Keyed by (ParentId, SortOrder), so these cannot be repointed onto the canonical view
+ // without risking a collision, and a view listing linked children is meaningless anyway.
+ await dbContext.LinkedChildren
+ .WhereOneOrMany(staleIds, e => e.ParentId)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ await dbContext.LinkedChildren
+ .WhereOneOrMany(staleIds, e => e.ChildId)
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
private async Task<UserView> PickSourceAsync(
JellyfinDbContext dbContext,
IReadOnlyList<UserView> stale,
@@ -294,8 +320,10 @@ internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine
IReadOnlyList<Guid> staleIds,
CancellationToken cancellationToken)
{
+ // Ancestry recorded against items that no longer exist is dead weight.
var items = await dbContext.AncestorIds
.WhereOneOrMany(staleIds, e => e.ParentItemId)
+ .Where(e => dbContext.BaseItems.Any(item => item.Id.Equals(e.ItemId)))
.Select(e => e.ItemId)
.Distinct()
.ToListAsync(cancellationToken)
diff --git a/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs b/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs
new file mode 100644
index 0000000000..43db38c92f
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260908120000_RepairAlternateVersionLinks.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Re-points every video that is linked as an alternate version at the primary it belongs to.
+/// </summary>
+[JellyfinMigration("2026-09-08T12:00:00", nameof(RepairAlternateVersionLinks))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+internal class RepairAlternateVersionLinks : IAsyncMigrationRoutine
+{
+ private const int BatchSize = 1000;
+
+ private readonly IStartupLogger<RepairAlternateVersionLinks> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RepairAlternateVersionLinks"/> class.
+ /// </summary>
+ /// <param name="logger">The startup logger.</param>
+ /// <param name="dbProvider">The database context factory.</param>
+ public RepairAlternateVersionLinks(
+ IStartupLogger<RepairAlternateVersionLinks> logger,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
+ {
+ _logger = logger;
+ _dbProvider = dbProvider;
+ }
+
+ /// <inheritdoc />
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var links = await dbContext.LinkedChildren
+ .Where(lc => lc.ChildType == LinkedChildType.LocalAlternateVersion
+ || lc.ChildType == LinkedChildType.LinkedAlternateVersion)
+ .Select(lc => new { lc.ParentId, lc.ChildId, lc.ChildType })
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (links.Count == 0)
+ {
+ _logger.LogInformation("No alternate version links found, nothing to repair.");
+ return;
+ }
+
+ // A version belongs to one primary; a file-based link outranks a user-merged one, as it
+ // does everywhere else these two link types meet.
+ var primaryByChild = links
+ .GroupBy(l => l.ChildId)
+ .ToDictionary(
+ g => g.Key,
+ g => g.OrderBy(l => l.ChildType == LinkedChildType.LocalAlternateVersion ? 0 : 1)
+ .First()
+ .ParentId);
+
+ // A version that is its own primary would be hidden from every list by the repair below.
+ foreach (var selfLink in primaryByChild.Where(kvp => kvp.Value.Equals(kvp.Key)).ToList())
+ {
+ _logger.LogWarning("Skipping alternate version {ChildId}, which is linked to itself.", selfLink.Key);
+ primaryByChild.Remove(selfLink.Key);
+ }
+
+ ResolvePrimaries(primaryByChild);
+
+ var repaired = 0;
+ var promoted = 0;
+
+ // The primaries are loaded along with their versions: a primary that carries a
+ // PrimaryVersionId of its own hides the whole group it heads.
+ var itemIds = primaryByChild.Keys.Concat(primaryByChild.Values).Distinct().ToList();
+ for (var offset = 0; offset < itemIds.Count; offset += BatchSize)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var batch = itemIds.GetRange(offset, Math.Min(BatchSize, itemIds.Count - offset));
+ var items = await dbContext.BaseItems
+ .Where(e => batch.Contains(e.Id))
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ foreach (var item in items)
+ {
+ if (primaryByChild.TryGetValue(item.Id, out var primaryId))
+ {
+ // Mirrors Video.CreatePresentationUniqueKey for a video that has a primary.
+ var expectedKey = primaryId.ToString("N", CultureInfo.InvariantCulture);
+ if (item.PrimaryVersionId.HasValue
+ && primaryId.Equals(item.PrimaryVersionId.Value)
+ && string.Equals(item.PresentationUniqueKey, expectedKey, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ item.PrimaryVersionId = primaryId;
+ item.PresentationUniqueKey = expectedKey;
+ repaired++;
+ }
+ else if (item.PrimaryVersionId.HasValue)
+ {
+ if (item.OwnerId.HasValue)
+ {
+ // An owned item is hidden by its owner rather than by its primary, so
+ // clearing the primary here would not bring the group back.
+ _logger.LogWarning(
+ "Alternate versions are linked to {ItemId}, which is owned by {OwnerId}; the group stays hidden until the owner is repaired.",
+ item.Id,
+ item.OwnerId.Value);
+ continue;
+ }
+
+ // Nothing links this one as a version, so the leftover primary is stale and
+ // would hide it, and with it every version linked to it.
+ _logger.LogWarning(
+ "Clearing the stale primary {PrimaryVersionId} of {ItemId}, which other versions are linked to.",
+ item.PrimaryVersionId.Value,
+ item.Id);
+
+ item.PrimaryVersionId = null;
+ item.PresentationUniqueKey = item.Id.ToString("N", CultureInfo.InvariantCulture);
+ promoted++;
+ }
+ }
+
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ _logger.LogInformation(
+ "Repaired {Repaired} of {Total} alternate version links, and promoted {Promoted} primaries that were versions themselves.",
+ repaired,
+ primaryByChild.Count,
+ promoted);
+ }
+ }
+
+ private void ResolvePrimaries(Dictionary<Guid, Guid> primaryByChild)
+ {
+ var resolvedPrimaries = new Dictionary<Guid, Guid>(primaryByChild.Count);
+
+ foreach (var start in primaryByChild.Keys.ToList())
+ {
+ if (resolvedPrimaries.ContainsKey(start))
+ {
+ continue;
+ }
+
+ var chain = new List<Guid>();
+ var walked = new HashSet<Guid>();
+ var current = start;
+ Guid primary;
+
+ while (true)
+ {
+ if (resolvedPrimaries.TryGetValue(current, out var resolved))
+ {
+ primary = resolved;
+ break;
+ }
+
+ if (!primaryByChild.TryGetValue(current, out var next))
+ {
+ // Nothing is linking this one as a version of something else, so it heads the group.
+ primary = current;
+ break;
+ }
+
+ if (!walked.Add(current))
+ {
+ var loop = chain.Skip(chain.IndexOf(current)).ToList();
+
+ // Which member heads the group is arbitrary; the lowest id keeps the repair
+ // stable if the migration is ever re-run over the same data.
+ primary = loop.Min();
+ _logger.LogWarning(
+ "Alternate version links form a loop ({Loop}); keeping {PrimaryId} as the primary of the group.",
+ string.Join(" -> ", loop),
+ primary);
+
+ primaryByChild.Remove(primary);
+ break;
+ }
+
+ chain.Add(current);
+ current = next;
+ }
+
+ foreach (var version in chain)
+ {
+ resolvedPrimaries[version] = primary;
+ }
+ }
+
+ foreach (var (version, primary) in resolvedPrimaries)
+ {
+ if (primary.Equals(version))
+ {
+ // The member of a loop that was kept as the primary of its group.
+ continue;
+ }
+
+ primaryByChild[version] = primary;
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs
index ed92c34aa3..a456acf47d 100644
--- a/Jellyfin.Server/Migrations/Routines/20260302090000_MigrateRatingLevels.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260910120000_MigrateRatingLevels.cs
@@ -11,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines;
/// Migrate rating levels.
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
-[JellyfinMigration("2026-03-02T09:00:00", nameof(MigrateRatingLevels))]
+[JellyfinMigration("2026-09-10T12:00:00", nameof(MigrateRatingLevels))]
[JellyfinMigrationBackup(JellyfinDb = true)]
#pragma warning restore CS0618 // Type or member is obsolete
internal class MigrateRatingLevels : IDatabaseMigrationRoutine
diff --git a/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs
new file mode 100644
index 0000000000..f61e42337a
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/20260911120000_StripEmbeddedLinkedChildren.cs
@@ -0,0 +1,44 @@
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Drops keys that no current property reads or writes from the serialized <c>BaseItems.Data</c> blob.
+/// </summary>
+[JellyfinMigration("2026-09-11T12:00:00", nameof(StripEmbeddedLinkedChildren))]
+internal class StripEmbeddedLinkedChildren : IDatabaseMigrationRoutine
+{
+ private readonly ILogger<StripEmbeddedLinkedChildren> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+
+ public StripEmbeddedLinkedChildren(
+ ILoggerFactory loggerFactory,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
+ {
+ _logger = loggerFactory.CreateLogger<StripEmbeddedLinkedChildren>();
+ _dbProvider = dbProvider;
+ }
+
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ using var context = _dbProvider.CreateDbContext();
+
+ // json_valid guards the rare malformed blob: json_remove would abort the statement on it,
+ // and one bad row must not cost every other row the fix.
+ var updated = context.Database.ExecuteSqlRaw(
+ """
+ UPDATE "BaseItems"
+ SET "Data" = json_remove("Data", '$.LinkedChildren', '$.ExtraIds', '$.SupportsExternalTransfer')
+ WHERE "Data" IS NOT NULL
+ AND json_valid("Data") = 1
+ AND ("Data" LIKE '%"LinkedChildren"%'
+ OR "Data" LIKE '%"ExtraIds"%'
+ OR "Data" LIKE '%"SupportsExternalTransfer"%')
+ """);
+
+ _logger.LogInformation("Dropped dead keys from the serialized data of {Count} items", updated);
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
index 971b47608f..71706811b8 100644
--- a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
+++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
@@ -4,8 +4,6 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Server.ServerSetupApp;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.DependencyInjection.Extensions;
-using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Stages;
@@ -22,66 +20,45 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!;
}
- private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
+ public async Task Perform(IServiceProvider serviceProvider, IStartupLogger logger, CancellationToken cancellationToken)
{
- var childServiceCollection = new ServiceCollection()
- .AddSingleton(serviceProvider)
- .AddSingleton(logger)
- .AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>))
- .AddSingleton<StartupLogTopic>(logger.Topic!);
+ if (!IsMigrationRoutine(MigrationType))
+ {
+ throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type");
+ }
- foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>())
+ // The routine runs against a scope of the applications own container. Copying the application service
+ // descriptors into a child container instead would make that child container the owner of every singleton it
+ // forwards, so disposing it after the migration would also dispose the applications own instance of services
+ // like the ProviderManager and leave the server broken until the next restart.
+ var scope = serviceProvider.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
{
- if (service.Lifetime == ServiceLifetime.Singleton && !service.ServiceType.IsGenericTypeDefinition)
+ // Nests everything the routine logs through an injected IStartupLogger under the migrations own topic.
+ using (StartupLogger.BeginAmbientTopic(logger.Topic))
{
- childServiceCollection.AddSingleton(service.ServiceType, _ => serviceProvider.GetService(service.ServiceType)!);
- continue;
+ await RunAsync(ActivatorUtilities.CreateInstance(scope.ServiceProvider, MigrationType), cancellationToken).ConfigureAwait(false);
}
-
- childServiceCollection.Add(service);
}
-
- return childServiceCollection;
}
- public async Task Perform(IServiceProvider? serviceProvider, IStartupLogger logger, CancellationToken cancellationToken)
- {
+ // The obsolete IMigrationRoutine is still implemented by every routine that predates the async interface, so
+ // the members that have to touch it are grouped here behind a single suppression.
#pragma warning disable CS0618 // Type or member is obsolete
- if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType))
- {
- if (serviceProvider is null)
- {
- ((IMigrationRoutine)Activator.CreateInstance(MigrationType)!).Perform();
- }
- else
- {
- using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
- ((IMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).Perform();
-#pragma warning restore CS0618 // Type or member is obsolete
- }
- }
- else if (typeof(IAsyncMigrationRoutine).IsAssignableFrom(MigrationType))
- {
- if (serviceProvider is null)
- {
- await ((IAsyncMigrationRoutine)Activator.CreateInstance(MigrationType)!).PerformAsync(cancellationToken).ConfigureAwait(false);
- }
- else
- {
- using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
- await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).PerformAsync(cancellationToken).ConfigureAwait(false);
- }
- }
- else
- {
- throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type");
- }
+ private static bool IsMigrationRoutine(Type migrationType)
+ {
+ return typeof(IMigrationRoutine).IsAssignableFrom(migrationType) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(migrationType);
}
- private class NestedStartupLogger<TCategory> : StartupLogger<TCategory>
+ private static async Task RunAsync(object routine, CancellationToken cancellationToken)
{
- public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic)
+ if (routine is IMigrationRoutine migrationRoutine)
{
+ migrationRoutine.Perform();
+ return;
}
+
+ await ((IAsyncMigrationRoutine)routine).PerformAsync(cancellationToken).ConfigureAwait(false);
}
+#pragma warning restore CS0618 // Type or member is obsolete
}
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index 12f92efb35..715fc3d3b9 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -61,6 +61,7 @@ namespace Jellyfin.Server
private static ILogger _logger = NullLogger.Instance;
private static bool _restartOnShutdown;
private static IStartupLogger<JellyfinMigrationService>? _migrationLogger;
+ private static bool _optimizeDatabaseAfterMigration;
private static string? _restoreFromBackup;
/// <summary>
@@ -180,9 +181,7 @@ namespace Jellyfin.Server
})
.ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig))
.UseSerilog()
- .ConfigureServices(e => e
- .RegisterStartupLogger()
- .AddSingleton<IServiceCollection>(e))
+ .ConfigureServices(e => e.RegisterStartupLogger())
.Build();
/*
@@ -209,14 +208,15 @@ namespace Jellyfin.Server
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);
+ _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
SetupServer.ReportActivity(StartupActivity.InitializingServices);
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
_appHost = appHost;
- await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
+ _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false);
+ await OptimizeDatabaseAfterMigrationAsync(appHost.ServiceProvider).ConfigureAwait(false);
try
{
configurationCompleted = true;
@@ -271,12 +271,10 @@ namespace Jellyfin.Server
// Don't throw additional exception if startup failed.
if (appHost.ServiceProvider is not null)
{
- _logger.LogInformation("Running query planner optimizations in the database... This might take a while");
+ _logger.LogInformation("Preparing the database for shutdown...");
var databaseProvider = appHost.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
- using var shutdownSource = new CancellationTokenSource();
- shutdownSource.CancelAfter((int)TimeSpan.FromSeconds(60).TotalMicroseconds);
- await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false);
+ await databaseProvider.RunShutdownTask(CancellationToken.None).ConfigureAwait(false);
}
_appHost = null;
@@ -307,14 +305,13 @@ namespace Jellyfin.Server
.AddSingleton<ServerApplicationPaths>(appPaths)
.RegisterStartupLogger();
- migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
PrepareDatabaseProvider(startupService);
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService);
await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false);
- await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
+ _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
}
/// <summary>
@@ -329,7 +326,32 @@ namespace Jellyfin.Server
public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage)
{
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider, _migrationLogger!);
- await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
+ _optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
+ }
+
+ private static async Task OptimizeDatabaseAfterMigrationAsync(IServiceProvider serviceProvider)
+ {
+ if (!_optimizeDatabaseAfterMigration)
+ {
+ return;
+ }
+
+ // Reset first: a restart runs no migrations and must not optimize again.
+ _optimizeDatabaseAfterMigration = false;
+ SetupServer.ReportActivity(StartupActivity.OptimizingDatabase);
+ _logger.LogInformation("Migrations have been applied, optimizing the database... This might take a while");
+
+ try
+ {
+ // Deliberately untimed: incomplete statistics are worse than a slow start.
+ var databaseProvider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
+ await databaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // A missed optimization only costs performance, so never fail startup over this.
+ _logger.LogError(ex, "Error while optimizing the database after migration");
+ }
}
/// <summary>
diff --git a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs
index 888cc617d4..abfc5bc8ba 100644
--- a/Jellyfin.Server/ServerSetupApp/StartupActivity.cs
+++ b/Jellyfin.Server/ServerSetupApp/StartupActivity.cs
@@ -27,6 +27,9 @@ public static class StartupActivity
/// <summary>Bringing up core services and plugins.</summary>
public const string InitializingServices = "Initializing services";
+ /// <summary>Refreshing the database statistics after migrations have run.</summary>
+ public const string OptimizingDatabase = "Optimizing database";
+
/// <summary>Running the final startup tasks.</summary>
public const string FinishingStartup = "Finishing startup";
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
index 0121854ce3..b72b0c0eab 100644
--- a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
+++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
@@ -1,5 +1,6 @@
using System;
using System.Globalization;
+using System.Threading;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -8,6 +9,8 @@ namespace Jellyfin.Server.ServerSetupApp;
/// <inheritdoc/>
public class StartupLogger : IStartupLogger
{
+ private static readonly AsyncLocal<StartupLogTopic?> _ambientTopic = new();
+
private readonly StartupLogTopic? _topic;
/// <summary>
@@ -17,6 +20,7 @@ public class StartupLogger : IStartupLogger
public StartupLogger(ILogger logger)
{
BaseLogger = logger;
+ _topic = _ambientTopic.Value;
}
/// <summary>
@@ -39,6 +43,18 @@ public class StartupLogger : IStartupLogger
/// </summary>
protected ILogger BaseLogger { get; set; }
+ /// <summary>
+ /// Makes <paramref name="topic"/> the topic that loggers created on this execution context attach to.
+ /// </summary>
+ /// <param name="topic">The topic to nest newly created loggers under.</param>
+ /// <returns>A scope that restores the previously ambient topic when disposed.</returns>
+ internal static IDisposable BeginAmbientTopic(StartupLogTopic? topic)
+ {
+ var scope = new AmbientTopicScope(_ambientTopic.Value);
+ _ambientTopic.Value = topic;
+ return scope;
+ }
+
/// <inheritdoc/>
public IStartupLogger BeginGroup(FormattableString logEntry)
{
@@ -121,4 +137,19 @@ public class StartupLogger : IStartupLogger
Topic.Children.Add(startupEntry);
}
}
+
+ private sealed class AmbientTopicScope : IDisposable
+ {
+ private readonly StartupLogTopic? _previous;
+
+ public AmbientTopicScope(StartupLogTopic? previous)
+ {
+ _previous = previous;
+ }
+
+ public void Dispose()
+ {
+ _ambientTopic.Value = _previous;
+ }
+ }
}
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 1802440dc4..560a419945 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -18,6 +18,7 @@ using Jellyfin.Networking.HappyEyeballs;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.HealthChecks;
using Jellyfin.Server.Implementations.Extensions;
+using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Extensions;
@@ -155,6 +156,7 @@ namespace Jellyfin.Server
services.AddHostedService<LibraryChangedNotifier>();
services.AddHostedService<UserDataChangeNotifier>();
services.AddHostedService<RecordingNotifier>();
+ services.AddHostedService<DeviceAccessHost>();
}
/// <summary>
diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj
index de07c7f2cb..1480a50c3b 100644
--- a/MediaBrowser.Common/MediaBrowser.Common.csproj
+++ b/MediaBrowser.Common/MediaBrowser.Common.csproj
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Common</PackageId>
- <VersionPrefix>12.0.0</VersionPrefix>
+ <VersionPrefix>13.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Controller/Dto/DtoOptions.cs b/MediaBrowser.Controller/Dto/DtoOptions.cs
index 052626355f..4259b7b65c 100644
--- a/MediaBrowser.Controller/Dto/DtoOptions.cs
+++ b/MediaBrowser.Controller/Dto/DtoOptions.cs
@@ -47,6 +47,20 @@ namespace MediaBrowser.Controller.Dto
}
/// <summary>
+ /// Gets options that populate nothing beyond the item's own stored columns.
+ /// </summary>
+ /// <remarks>
+ /// Each enabled field group is a collection the item query left-joins, so the rows returned are
+ /// the product of the item's provider, image and user data counts. Never use this for items that
+ /// get saved back: saving rewrites the owned rows from what the instance holds.
+ /// </remarks>
+ public static DtoOptions StoredColumnsOnly => new(false)
+ {
+ EnableImages = false,
+ EnableUserData = false
+ };
+
+ /// <summary>
/// Gets or sets the fields to populate on the DTO.
/// </summary>
public IReadOnlyList<ItemFields> Fields { get; set; }
diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs
index a02802f41e..ef24f632ed 100644
--- a/MediaBrowser.Controller/Entities/AggregateFolder.cs
+++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs
@@ -54,6 +54,24 @@ namespace MediaBrowser.Controller.Entities
public string[] PhysicalLocationsList { get; set; }
+ // Children caches the resolved items, _childrenIds the ids they were loaded from. Clearing
+ // only the former sends the next read back through LoadChildren, which replays the stale
+ // id list, so a caller invalidating this folder has to drop both.
+ [JsonIgnore]
+ public override IEnumerable<BaseItem> Children
+ {
+ get => base.Children;
+ set
+ {
+ if (value is null)
+ {
+ ClearCache();
+ }
+
+ base.Children = value;
+ }
+ }
+
public override bool CanDelete()
{
return false;
diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs
index d016d8f62b..281a98dad5 100644
--- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs
+++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs
@@ -165,6 +165,18 @@ namespace MediaBrowser.Controller.Entities.Audio
public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
{
+ try
+ {
+ await RefreshAllMetadataInternal(refreshOptions, progress, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ ReleaseCachedChildren();
+ }
+ }
+
+ private async Task RefreshAllMetadataInternal(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
+ {
var items = GetRecursiveChildren();
var totalItems = items.Count;
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index d030c8f420..70e7da8932 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -1411,7 +1411,8 @@ namespace MediaBrowser.Controller.Entities
/// 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).
+ /// VideoFlagDelimiters), except that a dot between digits is a decimal point rather than a
+ /// delimiter, so numeric version labels stay whole.
/// </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>
@@ -1445,9 +1446,12 @@ namespace MediaBrowser.Controller.Entities
if (!prefixIsWholeName)
{
- // Retreat to the last structural delimiter ('-', '_', '.').
+ // Retreat to the last structural delimiter ('-', '_', '.'), skipping dots that are
+ // decimal points within a number rather than delimiters (see IsDecimalPoint).
var cut = prefix.Length;
- while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0)
+ while (cut > 0
+ && (Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0
+ || IsDecimalPoint(prefix, cut - 1, fileNames)))
{
cut--;
}
@@ -1467,6 +1471,31 @@ namespace MediaBrowser.Controller.Entities
return prefix;
}
+ private static bool IsDecimalPoint(string prefix, int index, IReadOnlyList<string> fileNames)
+ {
+ if (index == 0 || prefix[index] != '.' || !char.IsDigit(prefix[index - 1]))
+ {
+ return false;
+ }
+
+ if (index + 1 < prefix.Length)
+ {
+ return char.IsDigit(prefix[index + 1]);
+ }
+
+ // The dot ends the prefix, so the character after it is the first one that differs between
+ // the versions: only a decimal point when every version continues the number.
+ for (var i = 0; i < fileNames.Count; i++)
+ {
+ if (fileNames[i].Length <= index + 1 || !char.IsDigit(fileNames[i][index + 1]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
public Task RefreshMetadata(CancellationToken cancellationToken)
{
return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index d8203ea6f2..626bc0d5a1 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -286,6 +286,27 @@ namespace MediaBrowser.Controller.Entities
return GetCachedChildren();
}
+ /// <summary>
+ /// Drops the children this folder has materialised, and the ones held by every folder below
+ /// it, without loading anything that is not already in memory.
+ /// </summary>
+ public void ReleaseCachedChildren()
+ {
+ // Cleared before descending, so a folder already on the way down is not walked twice.
+ var children = _children;
+ _children = null;
+
+ if (children is null)
+ {
+ return;
+ }
+
+ foreach (var child in children)
+ {
+ (child as Folder)?.ReleaseCachedChildren();
+ }
+ }
+
public override double? GetRefreshProgress()
{
return ProviderManager.GetRefreshProgress(Id);
@@ -316,7 +337,7 @@ namespace MediaBrowser.Controller.Entities
var dictionary = new Dictionary<Guid, BaseItem>();
Children = null; // invalidate cached children.
- var childrenList = Children.ToList();
+ var childrenList = GetChildrenForValidation();
foreach (var child in childrenList)
{
@@ -370,6 +391,9 @@ namespace MediaBrowser.Controller.Entities
{
ProviderManager.OnRefreshComplete(this);
}
+
+ // The subtree is done with, so stop holding it.
+ ReleaseCachedChildren();
}
}
@@ -405,19 +429,23 @@ namespace MediaBrowser.Controller.Entities
if (IsFileProtocol)
{
- IEnumerable<BaseItem> nonCachedChildren = [];
+ IEnumerable<BaseItem> nonCachedChildren;
try
{
- nonCachedChildren = GetNonCachedChildren(directoryService);
+ // Finish enumeration before mutating the library. An I/O failure, including
+ // one partway through a lazy enumeration, must not look like removed files.
+ nonCachedChildren = GetNonCachedChildren(directoryService).ToArray();
}
catch (IOException ex)
{
Logger.LogError(ex, "Error retrieving children from file system");
+ return;
}
catch (SecurityException ex)
{
Logger.LogError(ex, "Error retrieving children from file system");
+ return;
}
catch (Exception ex)
{
@@ -551,7 +579,7 @@ namespace MediaBrowser.Controller.Entities
&& primaryVideo.OwnerId.IsEmpty()
&& (primaryVideo.LocalAlternateVersions ?? []).Any(p => alternateVersionPaths.Contains(p)))
{
- var newPrimary = newItems
+ var newPrimary = validChildren
.OfType<Video>()
.FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
.Any(p => (primaryVideo.LocalAlternateVersions ?? [])
@@ -593,6 +621,8 @@ namespace MediaBrowser.Controller.Entities
newPrimary.Name,
newPrimary.Id);
+ await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false);
+
// Reroute collection/playlist references from old primary to new primary
await LibraryManager.RerouteLinkedChildReferencesAsync(oldPrimary.Id, newPrimary.Id).ConfigureAwait(false);
@@ -621,9 +651,12 @@ namespace MediaBrowser.Controller.Entities
LibraryManager.DeleteItem(oldPrimary, new DeleteOptions { DeleteFileLocation = false }, this, false);
}
- // Demote old primaries that are now alternate versions of newly created primaries.
+ // Demote old primaries that are now alternate versions of another primary.
// This handles the case where a new file is added that becomes the new primary
- // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion).
+ // (e.g. movie-2 added, movie-3 was primary → movie-3 needs demotion), and the case
+ // where the file that takes over was already in the library and merely traded
+ // places with this one — so the new primary is looked up among all valid children
+ // rather than only the newly created ones.
// Items in replacedPrimaries are excluded (already in actuallyRemoved).
var oldPrimariesToDemote = new List<(Video OldPrimary, Video NewPrimary)>();
foreach (var item in itemsRemoved.Except(actuallyRemoved))
@@ -633,7 +666,7 @@ namespace MediaBrowser.Controller.Entities
&& !string.IsNullOrEmpty(item.Path)
&& alternateVersionPaths.Contains(item.Path))
{
- var newPrimary = newItems
+ var newPrimary = validChildren
.OfType<Video>()
.FirstOrDefault(v => (v.LocalAlternateVersions ?? [])
.Any(p => string.Equals(p, item.Path, StringComparison.OrdinalIgnoreCase)));
@@ -653,10 +686,13 @@ namespace MediaBrowser.Controller.Entities
newPrimary.Name,
newPrimary.Id);
+ await PromoteToPrimaryVersionAsync(newPrimary, cancellationToken).ConfigureAwait(false);
+
// First: update old primary's alternate items to point to new primary.
// Order matters — update alternates FIRST so they don't get orphan-deleted
// when old primary's arrays are cleared.
- var oldAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary)
+ var oldLocalAlternateIds = LibraryManager.GetLocalAlternateVersionIds(oldPrimary).ToHashSet();
+ var oldAlternateIds = oldLocalAlternateIds
.Concat(LibraryManager.GetLinkedAlternateVersions(oldPrimary).Select(v => v.Id))
.Distinct()
.ToList();
@@ -666,7 +702,10 @@ namespace MediaBrowser.Controller.Entities
if (LibraryManager.GetItemById(altId) is Video altVideo && !altVideo.Id.Equals(newPrimary.Id))
{
altVideo.SetPrimaryVersionId(newPrimary.Id);
- altVideo.OwnerId = newPrimary.Id;
+
+ // Only a version stored next to the new primary is owned by it; one that
+ // was merged in by hand keeps its own row and must stay unowned.
+ altVideo.OwnerId = oldLocalAlternateIds.Contains(altVideo.Id) ? newPrimary.Id : Guid.Empty;
await altVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
@@ -772,6 +811,23 @@ namespace MediaBrowser.Controller.Entities
}
}
+ private async Task PromoteToPrimaryVersionAsync(Video newPrimary, CancellationToken cancellationToken)
+ {
+ if (!newPrimary.PrimaryVersionId.HasValue && newPrimary.OwnerId.IsEmpty())
+ {
+ return;
+ }
+
+ Logger.LogInformation(
+ "Promoting {Name} ({Id}) to the primary version of its group",
+ newPrimary.Name,
+ newPrimary.Id);
+
+ newPrimary.SetPrimaryVersionId(null);
+ newPrimary.OwnerId = Guid.Empty;
+ await newPrimary.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+ }
+
private async Task RefreshMetadataRecursive(IList<BaseItem> children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken)
{
await RunTasks(
@@ -807,7 +863,14 @@ namespace MediaBrowser.Controller.Entities
if (recursive && child is Folder folder)
{
folder.Children = null; // invalidate cached children.
- await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ folder.ReleaseCachedChildren();
+ }
}
}
}
@@ -876,6 +939,17 @@ namespace MediaBrowser.Controller.Entities
});
}
+ private IReadOnlyList<BaseItem> GetChildrenForValidation()
+ {
+ return ItemRepository.GetItemList(new InternalItemsQuery
+ {
+ Parent = this,
+ GroupByPresentationUniqueKey = false,
+ IncludeAlternateVersions = true,
+ DtoOptions = new DtoOptions(true)
+ });
+ }
+
public virtual int GetChildCount(User user)
{
if (LinkedChildren.Length > 0)
@@ -1613,7 +1687,17 @@ namespace MediaBrowser.Controller.Entities
/// <returns>IEnumerable{BaseItem}.</returns>
public List<BaseItem> GetLinkedChildren()
{
- var resolved = ResolveLinkedChildren(LinkedChildren);
+ return GetLinkedChildren(new DtoOptions());
+ }
+
+ /// <summary>
+ /// Gets the linked children, populating only what <paramref name="options"/> asks for.
+ /// </summary>
+ /// <param name="options">Fields to populate on the resolved children.</param>
+ /// <returns>The resolved children.</returns>
+ public List<BaseItem> GetLinkedChildren(DtoOptions options)
+ {
+ var resolved = ResolveLinkedChildren(LinkedChildren, options);
var list = new List<BaseItem>(resolved.Count);
foreach (var (_, item) in resolved)
{
@@ -1730,8 +1814,9 @@ namespace MediaBrowser.Controller.Entities
/// path (legacy path-based resolution).
/// </summary>
/// <param name="linkedChildren">Linked children to resolve.</param>
+ /// <param name="options">Fields to populate on the resolved items; all fields when null.</param>
/// <returns>Each input entry paired with its resolved item; entries that fail to resolve are dropped.</returns>
- private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren)
+ private List<(LinkedChild Info, BaseItem Item)> ResolveLinkedChildren(IReadOnlyList<LinkedChild> linkedChildren, DtoOptions options = null)
{
var resolved = new List<(LinkedChild Info, BaseItem Item)>(linkedChildren.Count);
if (linkedChildren.Count == 0)
@@ -1753,7 +1838,8 @@ namespace MediaBrowser.Controller.Entities
{
var batched = LibraryManager.GetItemList(new InternalItemsQuery
{
- ItemIds = [.. idsToBatch]
+ ItemIds = [.. idsToBatch],
+ DtoOptions = options ?? new DtoOptions()
});
byId = new Dictionary<Guid, BaseItem>(batched.Count);
foreach (var item in batched)
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 0e5a5047cd..7c88d5dd05 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -432,12 +432,18 @@ namespace MediaBrowser.Controller.Entities
public string? HasNoSubtitleTrackWithLanguage { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether to return only items nothing names any more.
+ /// </summary>
public bool? IsDeadArtist { get; set; }
public bool? IsDeadStudio { get; set; }
public bool? IsDeadGenre { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether to return only items nothing names any more.
+ /// </summary>
public bool? IsDeadPerson { get; set; }
/// <summary>
@@ -482,6 +488,14 @@ namespace MediaBrowser.Controller.Entities
/// </summary>
public bool IncludeOwnedItems { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether to include alternate versions, which carry a
+ /// <see cref="Video.PrimaryVersionId"/> and are normally hidden behind the version they
+ /// belong to. Unlike <see cref="IncludeOwnedItems"/> this keeps the versions a user merged
+ /// by hand without also returning the parts and extras owned by another item.
+ /// </summary>
+ public bool IncludeAlternateVersions { get; set; }
+
public bool? Is4K { get; set; }
public int? MaxHeight { get; set; }
diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
index 8216937cad..16d8bfe391 100644
--- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
+++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
@@ -11,6 +11,7 @@ using Jellyfin.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
+using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Querying;
@@ -89,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies
return base.GetNonCachedChildren(directoryService);
}
- return Enumerable.Empty<BaseItem>();
+ return [];
}
protected override IReadOnlyList<BaseItem> LoadChildren()
@@ -168,14 +169,21 @@ namespace MediaBrowser.Controller.Entities.Movies
return true;
}
- var userLibraryFolderIds = GetLibraryFolderIds(user);
- var libraryFolderIds = LibraryFolderIds ?? GetLibraryFolderIds();
+ List<BaseItem> linkedItems = null;
+ var libraryFolderIds = LibraryFolderIds;
+ if (libraryFolderIds is null)
+ {
+ linkedItems = GetLinkedChildren(DtoOptions.StoredColumnsOnly);
+ libraryFolderIds = GetLibraryFolderIds(linkedItems);
+ }
if (libraryFolderIds.Length == 0)
{
return true;
}
+ var userLibraryFolderIds = GetLibraryFolderIds(user);
+
if (!userLibraryFolderIds.Any(i => libraryFolderIds.Contains(i)))
{
return false;
@@ -184,7 +192,7 @@ namespace MediaBrowser.Controller.Entities.Movies
// If user has parental controls, hide the BoxSet when all children are restricted
if (user.MaxParentalRatingScore.HasValue)
{
- var linkedItems = GetLinkedChildren();
+ linkedItems ??= GetLinkedChildren(DtoOptions.StoredColumnsOnly);
if (linkedItems.Count > 0 && linkedItems.All(child => !child.IsParentalAllowed(user, true)))
{
return false;
@@ -241,10 +249,19 @@ namespace MediaBrowser.Controller.Entities.Movies
public Guid[] GetLibraryFolderIds()
{
- var expandedFolders = new List<Guid>();
+ return GetLibraryFolderIds(GetLinkedChildren(DtoOptions.StoredColumnsOnly));
+ }
+
+ private Guid[] GetLibraryFolderIds(IEnumerable<BaseItem> linkedChildren)
+ {
+ // Seeded with this box set so a cycle through a nested collection terminates.
+ var expandedFolders = new List<Guid> { Id };
+
+ // The user root children are the same for every item.
+ var rootChildren = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();
- return FlattenItems(this, expandedFolders)
- .SelectMany(LibraryManager.GetCollectionFolders)
+ return FlattenItems(linkedChildren, expandedFolders)
+ .SelectMany(i => LibraryManager.GetCollectionFolders(i, rootChildren))
.Select(i => i.Id)
.Distinct()
.ToArray();
@@ -264,13 +281,13 @@ namespace MediaBrowser.Controller.Entities.Movies
{
expandedFolders.Add(item.Id);
- return FlattenItems(boxset.GetLinkedChildren(), expandedFolders);
+ return FlattenItems(boxset.GetLinkedChildren(DtoOptions.StoredColumnsOnly), expandedFolders);
}
- return Array.Empty<BaseItem>();
+ return [];
}
- return new[] { item };
+ return [item];
}
}
}
diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index 1a1da84b7a..126f4361ba 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -333,6 +333,19 @@ namespace MediaBrowser.Controller.Entities.TV
public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
{
Children = null; // invalidate cached children.
+
+ try
+ {
+ await RefreshAllMetadataInternal(refreshOptions, progress, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ ReleaseCachedChildren();
+ }
+ }
+
+ private async Task RefreshAllMetadataInternal(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
+ {
// Refresh bottom up, seasons and episodes first, then the series
var items = GetRecursiveChildren();
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index f9ad2d86e6..82256cd964 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -455,25 +455,34 @@ namespace MediaBrowser.Controller.Entities
{
var itemList = filtered.ToList();
var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList();
+ var leaves = itemList.Where(i => i is not Folder).ToList();
+ var isPlayedValue = query.IsPlayed.Value;
- if (folderIds.Count > 0)
- {
- var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
- var isPlayedValue = query.IsPlayed.Value;
+ var counts = folderIds.Count > 0
+ ? libraryManager.GetPlayedAndTotalCountBatch(folderIds, user)
+ : null;
+
+ // A movie held as several files is watched once any of its versions is watched.
+ var resumeData = leaves.Count > 0
+ ? userDataManager.GetResumeUserDataBatch(leaves, user)
+ : null;
- return itemList.Where(item =>
+ return itemList.Where(item =>
+ {
+ if (item is Folder)
{
- if (item is Folder)
- {
- var itemCount = counts.GetValueOrDefault(item.Id);
- return (itemCount.Played >= itemCount.Total) == isPlayedValue;
- }
+ var itemCount = counts?.GetValueOrDefault(item.Id) ?? default;
+ return (itemCount.Played >= itemCount.Total) == isPlayedValue;
+ }
- return true;
- });
- }
+ var played = userDataManager.GetUserData(user, item)?.Played ?? false;
+ if (!played && resumeData is not null && resumeData.TryGetValue(item.Id, out var versionData))
+ {
+ played = versionData.UserData.Played;
+ }
- return itemList;
+ return played == isPlayedValue;
+ });
}
return filtered;
@@ -606,19 +615,7 @@ namespace MediaBrowser.Controller.Entities
}
}
- if (query.IsPlayed.HasValue)
- {
- // Folder.IsPlayed() hits the DB per-item (N+1 queries).
- // Folders are batch-filtered by the collection Filter() overload.
- if (!item.IsFolder)
- {
- userData ??= userDataManager.GetUserData(user, item);
- if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
- {
- return false;
- }
- }
- }
+ // IsPlayed is answered by the collection Filter() overload for folders and leaves alike.
if (query.IsLocked.HasValue)
{
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index c8cca1fa93..82de3546f0 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -107,6 +107,13 @@ namespace MediaBrowser.Controller.Library
Person? GetPerson(string name);
/// <summary>
+ /// Gets a Person, creating and persisting it if no item exists for the name yet.
+ /// </summary>
+ /// <param name="name">The name of the person.</param>
+ /// <returns>The person.</returns>
+ Person GetOrCreatePerson(string name);
+
+ /// <summary>
/// Finds the by path.
/// </summary>
/// <param name="path">The path.</param>
@@ -153,15 +160,6 @@ namespace MediaBrowser.Controller.Library
Year GetYear(int value);
/// <summary>
- /// Validate and refresh the People sub-set of the IBN.
- /// The items are stored in the db but not loaded into memory until actually requested by an operation.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken);
-
- /// <summary>
/// Reloads the root media folder.
/// </summary>
/// <param name="progress">The progress.</param>
@@ -709,6 +707,14 @@ namespace MediaBrowser.Controller.Library
/// <returns><c>true</c> if ignored, <c>false</c> otherwise.</returns>
bool IgnoreFile(FileSystemMetadata file, BaseItem parent);
+ /// <summary>
+ /// Gets the id a <see cref="Person"/> item for the name would have, without looking it up
+ /// or creating it.
+ /// </summary>
+ /// <param name="name">The name of the person.</param>
+ /// <returns>The item id for the name.</returns>
+ Guid GetPersonId(string name);
+
Guid GetStudioId(string name);
Guid GetGenreId(string name);
@@ -754,6 +760,18 @@ namespace MediaBrowser.Controller.Library
ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user);
/// <summary>
+ /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned
+ /// item value - artists, genres and studios - are answered in one set of queries for the
+ /// whole batch; the rest fall back to one query per item.
+ /// </summary>
+ /// <param name="kind">The kind of the name items.</param>
+ /// <param name="ids">The IDs of the name items.</param>
+ /// <param name="relatedItemKinds">The item kinds to count.</param>
+ /// <param name="user">The user for access filtering.</param>
+ /// <returns>The item counts of each requested id.</returns>
+ Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user);
+
+ /// <summary>
/// Batch-fetches child counts for multiple parent folders.
/// Returns the count of immediate children (non-recursive) for each parent.
/// </summary>
@@ -815,6 +833,13 @@ namespace MediaBrowser.Controller.Library
QueryFiltersLegacy GetQueryFiltersLegacy(InternalItemsQuery query);
/// <summary>
+ /// Gets a list of all distinct tags of the matching items.
+ /// </summary>
+ /// <param name="query">The query filter.</param>
+ /// <returns>List of tags.</returns>
+ IReadOnlyList<string> GetTagNames(InternalItemsQuery query);
+
+ /// <summary>
/// Gets a list of all language codes of the provided stream type.
/// </summary>
/// <param name="mediaStreamType">The stream type.</param>
diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
index 6da398129a..be75117b6f 100644
--- a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
+++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
@@ -17,7 +17,8 @@ namespace MediaBrowser.Controller.LibraryTaskScheduler;
/// </summary>
public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable
{
- private const int CleanupGracePeriod = 60;
+ private static readonly TimeSpan _cleanupGracePeriod = TimeSpan.FromSeconds(60);
+
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger;
private readonly IServerConfigurationManager _serverConfigurationManager;
@@ -31,6 +32,8 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
private readonly Lock _taskLock = new();
private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
+ private readonly CancellationTokenSource _disposeTokenSource = new();
+ private readonly TimeSpan _gracePeriod;
private volatile int _workCounter;
private Task? _cleanupTask;
@@ -46,10 +49,34 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
IHostApplicationLifetime hostApplicationLifetime,
ILogger<LimitedConcurrencyLibraryScheduler> logger,
IServerConfigurationManager serverConfigurationManager)
+ : this(hostApplicationLifetime, logger, serverConfigurationManager, _cleanupGracePeriod)
+ {
+ }
+
+ internal LimitedConcurrencyLibraryScheduler(
+ IHostApplicationLifetime hostApplicationLifetime,
+ ILogger<LimitedConcurrencyLibraryScheduler> logger,
+ IServerConfigurationManager serverConfigurationManager,
+ TimeSpan gracePeriod)
{
_hostApplicationLifetime = hostApplicationLifetime;
_logger = logger;
_serverConfigurationManager = serverConfigurationManager;
+ _gracePeriod = gracePeriod;
+ }
+
+ /// <summary>
+ /// Gets the number of runners the scheduler currently keeps alive.
+ /// </summary>
+ internal int ActiveRunnerCount
+ {
+ get
+ {
+ lock (_taskLock)
+ {
+ return _taskRunners.Count;
+ }
+ }
}
private void ScheduleTaskCleanup()
@@ -68,31 +95,65 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
async Task RunCleanupTask()
{
- _logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod);
- await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false);
- if (_disposed)
+ while (true)
{
- _logger.LogDebug("Abort cleaning up, already disposed.");
- return;
- }
+ _logger.LogDebug("Schedule cleanup task in {CleanupGracePeriod}.", _gracePeriod);
+ try
+ {
+ await Task.Delay(_gracePeriod, _disposeTokenSource.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogDebug("Abort cleaning up, already disposed.");
+ return;
+ }
- lock (_taskLock)
- {
- if (_tasks.Reader.Count > 0 || _workCounter > 0)
+ if (_disposed)
{
- _logger.LogDebug("Delay cleanup task, operations still running.");
- // tasks are still there so its still in use. Reschedule cleanup task.
- // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended.
- _cleanupTask = RunCleanupTask();
+ _logger.LogDebug("Abort cleaning up, already disposed.");
return;
}
+
+ CancellationTokenSource[] runners;
+ lock (_taskLock)
+ {
+ if (_tasks.Reader.Count > 0 || _workCounter > 0)
+ {
+ _logger.LogDebug("Delay cleanup task, operations still running.");
+ // tasks are still there so its still in use. Wait another grace period.
+ // we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended.
+ continue;
+ }
+
+ runners = [.. _taskRunners.Keys];
+
+ // Retire the runners before they are told to stop: an operation starting while
+ // they wind down must spawn its own instead of counting these towards the fanout.
+ _taskRunners.Clear();
+
+ // Hand the next operation the ability to schedule a cleanup again. Without this
+ // the very first cleanup would be the only one that ever runs.
+ _cleanupTask = null;
+ }
+
+ _logger.LogDebug("Cleanup runners.");
+ await StopRunners(runners).ConfigureAwait(false);
+ return;
}
+ }
+ }
- _logger.LogDebug("Cleanup runners.");
- foreach (var item in _taskRunners.ToArray())
+ private static async Task StopRunners(CancellationTokenSource[] runners)
+ {
+ foreach (var runner in runners)
+ {
+ try
{
- await item.Key.CancelAsync().ConfigureAwait(false);
- _taskRunners.Remove(item.Key);
+ await runner.CancelAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ // The runner already stopped on its own and disposed its stop source.
}
}
}
@@ -127,12 +188,17 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
{
var stopToken = new CancellationTokenSource();
var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping);
+
+ // Keyed on its own stop source, because cancelling that is what reaches the linked
+ // source the runner waits on. Cancellation does not travel the other way.
+ // Started without the runner's own token: a task cancelled before it is scheduled
+ // never runs its body, so it would never take itself out of _taskRunners again.
_taskRunners.Add(
- combinedSource,
+ stopToken,
Task.Factory.StartNew(
ItemWorker,
- (combinedSource, stopToken),
- combinedSource.Token,
+ (stopToken, combinedSource),
+ CancellationToken.None,
TaskCreationOptions.PreferFairness,
TaskScheduler.Default));
}
@@ -145,7 +211,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
_deadlockDetector.Value = stopToken.TaskStop;
try
{
- while (!stopToken.GlobalStop.Token.IsCancellationRequested)
+ while (!stopToken.GlobalStop.IsCancellationRequested)
{
var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
try
@@ -162,15 +228,24 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
}
}
}
- catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested)
+ catch (OperationCanceledException) when (stopToken.GlobalStop.IsCancellationRequested)
{
// thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose.
}
+ catch (ChannelClosedException)
+ {
+ // the scheduler was disposed and will not hand out any more work.
+ }
finally
{
_logger.LogDebug("Cleanup Runner'.");
_deadlockDetector.Value = default!;
- _taskRunners.Remove(stopToken.TaskStop);
+
+ lock (_taskLock)
+ {
+ _taskRunners.Remove(stopToken.TaskStop);
+ }
+
stopToken.GlobalStop.Dispose();
stopToken.TaskStop.Dispose();
}
@@ -195,7 +270,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
finally
{
item.Progress.Report(100);
- item.Done.SetResult();
+ item.Done.TrySetResult();
}
}
@@ -285,16 +360,33 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
_disposed = true;
_tasks.Writer.Complete();
- foreach (var item in _taskRunners)
+
+ // Nobody is left to run these, so release whoever is waiting on them.
+ while (_tasks.Reader.TryRead(out var item))
{
- await item.Key.CancelAsync().ConfigureAwait(false);
+ item.Done.TrySetResult();
}
- if (_cleanupTask is not null)
+ CancellationTokenSource[] runners;
+ Task? cleanupTask;
+ lock (_taskLock)
{
- await _cleanupTask.ConfigureAwait(false);
- _cleanupTask?.Dispose();
+ runners = [.. _taskRunners.Keys];
+ _taskRunners.Clear();
+ cleanupTask = _cleanupTask;
}
+
+ await StopRunners(runners).ConfigureAwait(false);
+
+ // Cuts the grace period short instead of holding up shutdown for the rest of it.
+ await _disposeTokenSource.CancelAsync().ConfigureAwait(false);
+
+ if (cleanupTask is not null)
+ {
+ await cleanupTask.ConfigureAwait(false);
+ }
+
+ _disposeTokenSource.Dispose();
}
private class TaskQueueItem
diff --git a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
index 8d59eef9f1..77e0087048 100644
--- a/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
+++ b/MediaBrowser.Controller/LiveTv/ILiveTvManager.cs
@@ -201,6 +201,13 @@ namespace MediaBrowser.Controller.LiveTv
IEnumerable<User> GetEnabledUsers();
/// <summary>
+ /// Gets whether Live TV is enabled for a single user.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <returns>Whether Live TV is enabled for the user.</returns>
+ bool IsEnabledForUser(User user);
+
+ /// <summary>
/// Gets the internal channels.
/// </summary>
/// <param name="query">The query.</param>
diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
index 06188ad511..da2ffdeb79 100644
--- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj
+++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Controller</PackageId>
- <VersionPrefix>12.0.0</VersionPrefix>
+ <VersionPrefix>13.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -18,7 +18,6 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="BitFaster.Caching" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
</ItemGroup>
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 10c21ee03c..6f010c0242 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -442,7 +442,8 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|| IsHdr10Plus(state.VideoStream)
|| IsDoviWithHdr10Bl(state.VideoStream)
- || state.VideoStream.VideoRangeType == VideoRangeType.HLG);
+ || state.VideoStream.VideoRangeType == VideoRangeType.HLG
+ || state.VideoStream.VideoRangeType == VideoRangeType.DOVIInvalid);
}
private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
@@ -695,7 +696,11 @@ namespace MediaBrowser.Controller.MediaEncoding
"ogg" or "oga" or "ogv" or "webm" or "webma" => "opus",
"m4a" or "m4b" or "mp4" or "mov" or "mkv" or "mka" => "aac",
"ts" or "avi" or "flv" or "f4v" or "swf" => "mp3",
- _ => inferredCodec
+ // Containers that share their name with the codec they carry.
+ "aac" or "ac3" or "alac" or "dts" or "eac3" or "flac" or "mp2" or "mp3" or "opus" or "truehd" or "vorbis" => inferredCodec,
+ // Anything else - manifests such as m3u8/mpd in particular - names a container that
+ // is not an audio codec. Never hand that name to ffmpeg as an encoder.
+ _ => "aac"
};
}
@@ -1386,7 +1391,8 @@ namespace MediaBrowser.Controller.MediaEncoding
or VideoRangeType.DOVIWithEL
or VideoRangeType.DOVIWithHDR10Plus
or VideoRangeType.DOVIWithELHDR10Plus
- or VideoRangeType.DOVIInvalid;
+ || (rangeType == VideoRangeType.DOVIInvalid
+ && string.Equals(stream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)); // invalid may be hlg now
}
public static bool IsDovi(MediaStream stream)
@@ -1396,7 +1402,8 @@ namespace MediaBrowser.Controller.MediaEncoding
return IsDoviWithHdr10Bl(stream)
|| (rangeType is VideoRangeType.DOVI
or VideoRangeType.DOVIWithHLG
- or VideoRangeType.DOVIWithSDR);
+ or VideoRangeType.DOVIWithSDR
+ or VideoRangeType.DOVIInvalid);
}
public static bool IsHdr10Plus(MediaStream stream)
@@ -1416,7 +1423,8 @@ namespace MediaBrowser.Controller.MediaEncoding
private static DynamicHdrMetadataRemovalPlan ShouldRemoveDynamicHdrMetadata(EncodingJobInfo state)
{
var videoStream = state.VideoStream;
- if (videoStream.VideoRange is not VideoRange.HDR)
+ if (videoStream.VideoRange is not VideoRange.HDR
+ && videoStream.VideoRangeType != VideoRangeType.DOVIInvalid)
{
return DynamicHdrMetadataRemovalPlan.None;
}
@@ -3799,6 +3807,11 @@ namespace MediaBrowser.Controller.MediaEncoding
var formatArg = isFormatFixed ? (":format=" + videoFormat) : string.Empty;
var tonemapArg = string.Empty;
+ // libplacebo only support full range RGB
+ forceFullRange = forceFullRange
+ || (videoFormat ?? string.Empty).Contains("rgb", StringComparison.OrdinalIgnoreCase)
+ || (videoFormat ?? string.Empty).Contains("bgr", StringComparison.OrdinalIgnoreCase);
+
if (doTonemap)
{
var algorithm = options.TonemappingAlgorithm;
@@ -3822,6 +3835,10 @@ namespace MediaBrowser.Controller.MediaEncoding
tonemapArg += ":range=" + range.ToString().ToLowerInvariant();
}
}
+ else if (forceFullRange)
+ {
+ formatArg += ":range=pc";
+ }
return string.Format(
CultureInfo.InvariantCulture,
@@ -5462,7 +5479,14 @@ namespace MediaBrowser.Controller.MediaEncoding
mainFilters.Add("format=vaapi");
// clear the surf->meta_offset and output nv12
- mainFilters.Add("scale_vaapi=format=nv12");
+ var hwCscFilter = "scale_vaapi=format=nv12";
+
+ if (!isMjpegEncoder && options.TonemappingRange != TonemappingRange.pc)
+ {
+ hwCscFilter += ":out_range=tv";
+ }
+
+ mainFilters.Add(hwCscFilter);
// hw deint
if (doDeintH2645)
@@ -5532,7 +5556,14 @@ namespace MediaBrowser.Controller.MediaEncoding
overlayFilters.Add("format=vaapi");
// clear the surf->meta_offset and output nv12
- overlayFilters.Add("scale_vaapi=format=nv12");
+ var hwCscFilter = "scale_vaapi=format=nv12";
+
+ if (!doVkTonemap || (doVkTonemap && options.TonemappingRange != TonemappingRange.pc))
+ {
+ hwCscFilter += ":out_range=tv";
+ }
+
+ overlayFilters.Add(hwCscFilter);
// hw deint
if (doDeintH2645)
@@ -6311,7 +6342,7 @@ namespace MediaBrowser.Controller.MediaEncoding
string.Join(',', overlayFilters));
var mapPrefix = Convert.ToInt32(state.SubtitleStream.IsExternal);
- var subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream);
+ var subtitleStreamIndex = GetSubtitleStreamIndexForFfmpeg(state.MediaSource, state.SubtitleStream);
var videoStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.VideoStream);
if (hasSubs)
@@ -7943,6 +7974,24 @@ namespace MediaBrowser.Controller.MediaEncoding
return -1;
}
+ public static int GetSubtitleStreamIndexForFfmpeg(MediaSourceInfo mediaSource, MediaStream subtitleStream)
+ {
+ var index = FindIndex(mediaSource.MediaStreams, subtitleStream);
+ if (index == -1 || subtitleStream.IsExternal || mediaSource.VideoType != VideoType.BluRay)
+ {
+ return index;
+ }
+
+ var hiddenStreamsBefore = mediaSource.MediaStreams.Count(s =>
+ s.Type == MediaStreamType.Audio
+ && !s.IsExternal
+ && (string.Equals(s.Codec, "truehd", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(s.Codec, "atmos", StringComparison.OrdinalIgnoreCase))
+ && s.Index < subtitleStream.Index);
+
+ return index + hiddenStreamsBefore;
+ }
+
public static bool IsCopyCodec(string codec)
{
return string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase);
diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs
index ca55340a05..50ed76f762 100644
--- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs
+++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs
@@ -4,7 +4,9 @@ using MediaBrowser.Model.Session;
namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
/// <summary>
-/// Force keep alive websocket messages.
+/// Force keep alive websocket messages. The data is the timeout in seconds after which the
+/// server considers the connection lost; clients are expected to answer with a KeepAlive
+/// message and to keep sending one at least every half of that timeout.
/// </summary>
public class ForceKeepAliveMessage : OutboundWebSocketMessage<int>
{
diff --git a/MediaBrowser.Controller/Persistence/IItemCountService.cs b/MediaBrowser.Controller/Persistence/IItemCountService.cs
index 8ddf93e3e0..14c422cb60 100644
--- a/MediaBrowser.Controller/Persistence/IItemCountService.cs
+++ b/MediaBrowser.Controller/Persistence/IItemCountService.cs
@@ -37,6 +37,18 @@ public interface IItemCountService
ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter);
/// <summary>
+ /// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned
+ /// item value - artists, genres and studios - are answered in one set of queries for the whole
+ /// batch; the rest fall back to one query per id.
+ /// </summary>
+ /// <param name="kind">The kind of the name items.</param>
+ /// <param name="ids">The IDs of the name items.</param>
+ /// <param name="relatedItemKinds">The item kinds to count.</param>
+ /// <param name="accessFilter">A pre-configured query with user access filtering settings.</param>
+ /// <returns>The item counts of each requested id.</returns>
+ Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter);
+
+ /// <summary>
/// Gets the count of played items that are descendants of the specified ancestor.
/// </summary>
/// <param name="filter">The query filter containing user access settings.</param>
diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs
index d44fe57bed..3cf06b897c 100644
--- a/MediaBrowser.Controller/Persistence/IItemRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs
@@ -129,6 +129,13 @@ public interface IItemRepository
public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType);
/// <summary>
+ /// Gets all distinct tags of the matching base items.
+ /// </summary>
+ /// <param name="filter">The query filter.</param>
+ /// <returns>The list of tags.</returns>
+ IReadOnlyList<string> GetTagNames(InternalItemsQuery filter);
+
+ /// <summary>
/// Gets all artist names.
/// </summary>
/// <returns>The list of artist names.</returns>
diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs
index fc367b8293..edf3fb25c9 100644
--- a/MediaBrowser.Controller/Playlists/Playlist.cs
+++ b/MediaBrowser.Controller/Playlists/Playlist.cs
@@ -235,18 +235,18 @@ namespace MediaBrowser.Controller.Playlists
{
if (!IsSharedItem)
{
- return base.IsVisible(user, skipAllowedTagsCheck);
+ return base.IsVisible(user, skipAllowedTagsCheck) && HasParentalAllowedChild(user);
}
if (OpenAccess)
{
- return true;
+ return HasParentalAllowedChild(user);
}
var userId = user.Id;
if (userId.Equals(OwnerUserId))
{
- return true;
+ return HasParentalAllowedChild(user);
}
var shares = Shares;
@@ -255,7 +255,19 @@ namespace MediaBrowser.Controller.Playlists
return false;
}
- return shares.Any(s => s.UserId.Equals(userId));
+ return shares.Any(s => s.UserId.Equals(userId)) && HasParentalAllowedChild(user);
+ }
+
+ private bool HasParentalAllowedChild(User user)
+ {
+ if (!user.MaxParentalRatingScore.HasValue)
+ {
+ return true;
+ }
+
+ var linkedItems = GetLinkedChildren();
+
+ return linkedItems.Count == 0 || linkedItems.Any(child => child.IsParentalAllowed(user, true));
}
public override bool CanDelete(User user)
diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs
index 6060d051a5..f8e0bf4ed9 100644
--- a/MediaBrowser.Controller/Providers/DirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/DirectoryService.cs
@@ -5,13 +5,19 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Threading;
using MediaBrowser.Model.IO;
namespace MediaBrowser.Controller.Providers
{
public class DirectoryService : IDirectoryService
{
- // TODO make static and switch to FastConcurrentLru.
+ // TODO replace with one shared bounded cache.
+ private const int MaxCachedRecords = 100_000;
+ private const int AccessIntervalMs = 1_000;
+ // Timeout cache if no access for 5 minutes.
+ private const int IdleTimeoutMs = 5 * 60 * 1_000;
+
private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new(StringComparer.Ordinal);
@@ -20,6 +26,12 @@ namespace MediaBrowser.Controller.Providers
private readonly IFileSystem _fileSystem;
+ // ConcurrentDictionary.Count locks the dictionary, so keep an estimated counter.
+ // Concurrent factory runs can overcount and a clear racing an add can undercount,
+ // it only has to be roughly right.
+ private int _recordCount;
+ private long _lastAccess = Environment.TickCount64;
+
public DirectoryService(IFileSystem fileSystem)
{
_fileSystem = fileSystem;
@@ -27,20 +39,26 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata[] GetFileSystemEntries(string path)
{
+ DropCacheIfIdleOrFull();
+
return _cache.GetOrAdd(
path,
- static (p, fileSystem) =>
+ static (p, state) =>
{
+ FileSystemMetadata[] entries;
try
{
- return fileSystem.GetFileSystemEntries(p).ToArray();
+ entries = state.FileSystem.GetFileSystemEntries(p).ToArray();
}
catch (DirectoryNotFoundException)
{
- return [];
+ entries = [];
}
+
+ Interlocked.Add(ref state.Service._recordCount, entries.Length + 1);
+ return entries;
},
- _fileSystem);
+ (FileSystem: _fileSystem, Service: this));
}
public List<FileSystemMetadata> GetDirectories(string path)
@@ -89,13 +107,18 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata? GetFileSystemEntry(string path)
{
+ DropCacheIfIdleOrFull();
+
if (!_fileCache.TryGetValue(path, out var result))
{
var file = _fileSystem.GetFileSystemInfo(path);
if (file?.Exists ?? false)
{
result = file;
- _fileCache.TryAdd(path, result);
+ if (_fileCache.TryAdd(path, result))
+ {
+ Interlocked.Increment(ref _recordCount);
+ }
}
}
@@ -107,32 +130,96 @@ namespace MediaBrowser.Controller.Providers
public IReadOnlyList<string> GetFilePaths(string path, bool clearCache)
{
- if (clearCache)
+ if (clearCache && _filePathCache.TryRemove(path, out var cached))
{
- _filePathCache.TryRemove(path, out _);
+ Interlocked.Add(ref _recordCount, -(cached.Count + 1));
}
+ DropCacheIfIdleOrFull();
+
var filePaths = _filePathCache.GetOrAdd(
path,
- static (p, fileSystem) =>
+ static (p, state) =>
{
+ List<string> filePaths;
try
{
- return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList();
+ filePaths = state.FileSystem.GetFilePaths(p).OrderBy(x => x).ToList();
}
catch (DirectoryNotFoundException)
{
- return [];
+ filePaths = [];
}
+
+ Interlocked.Add(ref state.Service._recordCount, filePaths.Count + 1);
+ return filePaths;
},
- _fileSystem);
+ (FileSystem: _fileSystem, Service: this));
return filePaths;
}
+ public void Invalidate(string path)
+ {
+ Forget(path);
+
+ var parent = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(parent))
+ {
+ Forget(parent);
+ }
+ }
+
+ public void Move(string source, string destination)
+ {
+ Directory.Move(source, destination);
+
+ Invalidate(source);
+ Invalidate(destination);
+ }
+
public bool IsAccessible(string path)
{
return _fileSystem.GetFileSystemEntryPaths(path).Any();
}
+
+ private void DropCacheIfIdleOrFull()
+ {
+ var nowMs = Environment.TickCount64;
+ var idleMs = nowMs - _lastAccess;
+
+ if (idleMs >= IdleTimeoutMs || _recordCount >= MaxCachedRecords)
+ {
+ _cache.Clear();
+ _fileCache.Clear();
+ _filePathCache.Clear();
+ _recordCount = 0;
+ _lastAccess = nowMs;
+ return;
+ }
+
+ if (idleMs >= AccessIntervalMs)
+ {
+ _lastAccess = nowMs;
+ }
+ }
+
+ private void Forget(string path)
+ {
+ if (_cache.TryRemove(path, out var entries))
+ {
+ Interlocked.Add(ref _recordCount, -(entries.Length + 1));
+ }
+
+ if (_fileCache.TryRemove(path, out _))
+ {
+ Interlocked.Decrement(ref _recordCount);
+ }
+
+ if (_filePathCache.TryRemove(path, out var filePaths))
+ {
+ Interlocked.Add(ref _recordCount, -(filePaths.Count + 1));
+ }
+ }
}
}
diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs
index 8a3fa33da3..3a943d5f0c 100644
--- a/MediaBrowser.Controller/Providers/IDirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs
@@ -23,6 +23,19 @@ namespace MediaBrowser.Controller.Providers
IReadOnlyList<string> GetFilePaths(string path, bool clearCache);
+ /// <summary>
+ /// Forgets what is cached about a path and about the directory containing it.
+ /// </summary>
+ /// <param name="path">The file or directory path that changed.</param>
+ void Invalidate(string path);
+
+ /// <summary>
+ /// Moves a directory and forgets what is cached about both paths.
+ /// </summary>
+ /// <param name="source">The directory to move.</param>
+ /// <param name="destination">The path to move the directory to.</param>
+ void Move(string source, string destination);
+
bool IsAccessible(string path);
}
}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
index eb38eeb503..8f17039ae1 100644
--- a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
@@ -50,6 +50,11 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
/// </summary>
private GroupStateType InitialState { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether the group position moved during this wait.
+ /// </summary>
+ private bool PositionJumped { get; set; }
+
/// <inheritdoc />
public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
{
@@ -136,6 +141,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
ResumePlaying = true;
var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks);
+ PositionJumped = setQueueStatus;
if (!setQueueStatus)
{
_logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString());
@@ -175,6 +181,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
ResumePlaying = true;
var result = context.SetPlayingItem(request.PlaylistItemId);
+ PositionJumped = result;
if (result)
{
var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem);
@@ -214,6 +221,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
{
ResumePlaying = true;
context.RestartCurrentItem();
+ PositionJumped = true;
var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist);
var update = new SyncPlayPlayQueueUpdate(context.GroupId, playQueueUpdate);
@@ -310,6 +318,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
// Seek.
context.PositionTicks = ticks;
context.LastActivity = DateTime.UtcNow;
+ PositionJumped = true;
var command = context.NewSyncPlayCommand(SendCommandType.Seek);
context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
@@ -450,7 +459,13 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
{
// Handle case where session reported as ready but in reality
// it has no clue of the real position nor the playback state.
- if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks)
+ // A jump means the session has not applied the new position; without one it is
+ // catching up after buffering and is allowed to lag.
+ var maxOffsetTicks = request.IsPlaying && !PositionJumped
+ ? TimeSpan.FromMilliseconds(context.MaxCatchUpOffset).Ticks
+ : maxPlaybackOffsetTicks;
+
+ if (Math.Abs(delayTicks) > maxOffsetTicks)
{
// Session not ready at all.
context.SetBuffering(session, true);
@@ -501,7 +516,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
{
// Client, that was buffering, resumed playback but did not update others in time.
delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond;
- delayTicks = Math.Max(delayTicks, context.DefaultPing);
+ delayTicks = Math.Max(delayTicks, TimeSpan.FromMilliseconds(context.DefaultPing).Ticks);
context.LastActivity = currentTime.AddTicks(delayTicks);
@@ -580,6 +595,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
}
var newItem = context.NextItemInQueue();
+ PositionJumped = newItem;
if (newItem)
{
// Send playing-queue update.
@@ -626,6 +642,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
}
var newItem = context.PreviousItemInQueue();
+ PositionJumped = newItem;
if (newItem)
{
// Send playing-queue update.
diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs
index ddf86be71f..e02d1bde45 100644
--- a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs
+++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs
@@ -34,6 +34,12 @@ namespace MediaBrowser.Controller.SyncPlay
long MaxPlaybackOffset { get; }
/// <summary>
+ /// Gets the maximum offset accepted for a session catching up after buffering, in milliseconds.
+ /// </summary>
+ /// <value>The maximum catch-up offset, in milliseconds.</value>
+ long MaxCatchUpOffset => 60000;
+
+ /// <summary>
/// Gets the group identifier.
/// </summary>
/// <value>The group identifier.</value>
diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
index 9326864d78..258b92e4d9 100644
--- a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
+++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
@@ -157,7 +157,10 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// </summary>
public void RestoreSortedPlaylist()
{
- if (PlayingItemIndex != NoPlayingItemIndex)
+ // The shuffled playlist is only populated while the shuffle mode is active, so there is
+ // nothing to map back when the playlist is already sorted. Guarding on its contents keeps
+ // a redundant request for the sorted mode from indexing an empty list.
+ if (PlayingItemIndex != NoPlayingItemIndex && _shuffledPlaylist.Count > 0)
{
var playingItem = _shuffledPlaylist[PlayingItemIndex];
PlayingItemIndex = _sortedPlaylist.IndexOf(playingItem);
diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs
index bc184d82fb..d5e233d4f8 100644
--- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs
+++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
@@ -500,7 +501,8 @@ namespace MediaBrowser.LocalMetadata.Savers
{
var batched = LibraryManager.GetItemList(new InternalItemsQuery
{
- ItemIds = [.. idsToResolve]
+ ItemIds = [.. idsToResolve],
+ DtoOptions = DtoOptions.StoredColumnsOnly
});
pathById = new Dictionary<Guid, string?>(batched.Count);
foreach (var batchedItem in batched)
diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs
index 6ca994fb7e..57c553386a 100644
--- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs
+++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs
@@ -87,7 +87,7 @@ public class BdInfoExaminer : IBlurayExaminer
if (playlist.StreamClips is not null && playlist.StreamClips.Count > 0)
{
// Get the files in the playlist
- outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.FileInfo.FullName).ToArray();
+ outputStream.Files = playlist.StreamClips.Where(i => i.AngleIndex == 0).Select(i => i.StreamFile.FileInfo.FullName).ToArray();
}
return outputStream;
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index 91d0c3d5a6..99c08eb9a1 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -193,8 +193,6 @@ namespace MediaBrowser.MediaEncoding.Encoder
private readonly string _encoderPath;
- private readonly Version _minFFmpegMultiThreadedCli = new Version(7, 0);
-
public EncoderValidator(ILogger logger, string encoderPath)
{
_logger = logger;
@@ -552,9 +550,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
string output;
try
{
- // With multi-threaded cli support, FFmpeg 7 is less sensitive to keyboard input
- var duration = ffmpegVersion >= _minFFmpegMultiThreadedCli ? 10000 : 1000;
- output = GetProcessOutput(_encoderPath, $"-hide_banner -f lavfi -i nullsrc=s=1x1:d={duration} -f null -", true, "?");
+ // Start a dummy encode of 1x1@1fps. Send '?' to stdin to get the help/keybind text, followed by 'q' to stop the job immediately
+ // As a safeguard in case 'q' doesn't stop the job, the dummy input has a max duration of 5 (realtime) seconds
+ output = GetProcessOutput(_encoderPath, $"-hide_banner -re -f lavfi -i nullsrc=s=1x1:r=1:d=5 -f null -", true, "?q");
}
catch (Exception ex)
{
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 0ddd378352..fa43d756a4 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -922,6 +922,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
inputArg = "-hwaccel_flags +low_priority " + inputArg;
}
+ // Force the video stream, otherwise ffmpeg may pick a cover image.
+ var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, imageStream);
+ if (streamIndex < 0)
+ {
+ throw new InvalidOperationException($"Unable to locate requested stream {imageStream.Title}");
+ }
+
+ inputArg += " -map 0:" + streamIndex;
+
var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, vidEncoder).Trim();
if (string.IsNullOrWhiteSpace(filterParam))
{
@@ -1152,6 +1161,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
{
process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
}
+ catch (InvalidOperationException)
+ {
+ // The process finished before its priority could be lowered. That says nothing
+ // about whether the platform allows it, so keep the capability for the next one.
+ }
catch (Exception ex)
{
_canSetProcessPriority = false;
@@ -1361,12 +1375,20 @@ namespace MediaBrowser.MediaEncoding.Encoder
return _configurationManager.GetEncodingOptions().EnableSubtitleExtraction;
}
- private sealed class ProcessWrapper : IDisposable
+ internal sealed class ProcessWrapper : IDisposable
{
private readonly MediaEncoder _mediaEncoder;
+ // The exit event is raised on the thread pool, so it writes the state below while the
+ // caller that started the process is reading it.
+ private readonly Lock _exitLock = new();
+
private bool _disposed = false;
+ private bool _hasExited;
+
+ private int? _exitCode;
+
public ProcessWrapper(Process process, MediaEncoder mediaEncoder)
{
Process = process;
@@ -1376,49 +1398,84 @@ namespace MediaBrowser.MediaEncoding.Encoder
public Process Process { get; }
- public bool HasExited { get; private set; }
+ // The exit event can lag behind the wait that returned, so ask the process rather than
+ // report one that has exited as still running.
+ public bool HasExited => ReadExitState().HasExited;
- public int? ExitCode { get; private set; }
+ // As above: rather than report no exit code for a process that has one.
+ public int? ExitCode => ReadExitState().ExitCode;
+
+ private (bool HasExited, int? ExitCode) ReadExitState()
+ {
+ lock (_exitLock)
+ {
+ if (!_hasExited && !_disposed)
+ {
+ try
+ {
+ if (Process.HasExited)
+ {
+ _hasExited = true;
+ _exitCode = Process.ExitCode;
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // No process is associated with this object, or it was disposed from
+ // under us - ObjectDisposedException derives from this one.
+ }
+ }
+
+ return (_hasExited, _exitCode);
+ }
+ }
private void OnProcessExited(object sender, EventArgs e)
{
var process = (Process)sender;
- HasExited = true;
-
- try
- {
- ExitCode = process.ExitCode;
- }
- catch
+ lock (_exitLock)
{
+ _hasExited = true;
+
+ try
+ {
+ _exitCode = process.ExitCode;
+ }
+ catch
+ {
+ }
}
- DisposeProcess(process);
+ // Only stop tracking it. The caller that started the process still holds it to read
+ // its output and its exit code, so disposing it here handed whoever was quickest to
+ // exit - an ffprobe on a file it rejects outright - an ObjectDisposedException.
+ Untrack();
}
- private void DisposeProcess(Process process)
+ private void Untrack()
{
lock (_mediaEncoder._runningProcessesLock)
{
_mediaEncoder._runningProcesses.Remove(this);
}
-
- process.Dispose();
}
public void Dispose()
{
- if (!_disposed)
+ lock (_exitLock)
{
- if (Process is not null)
+ if (_disposed)
{
- Process.Exited -= OnProcessExited;
- DisposeProcess(Process);
+ return;
}
+
+ _disposed = true;
}
- _disposed = true;
+ Process.Exited -= OnProcessExited;
+ Untrack();
+ Process.Dispose();
}
}
}
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index e8c636e7fb..fba644b74a 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -649,7 +649,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
List<MediaStream> subtitleStreams,
CancellationToken cancellationToken)
{
- var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
+ var inputPath = _mediaEncoder.GetInputPathArgument(mediaSource.Path, mediaSource);
var outputPaths = new List<string>();
var args = string.Format(
CultureInfo.InvariantCulture,
@@ -673,7 +673,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
// FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files.
var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty;
- var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
+ var streamIndex = EncodingHelper.GetSubtitleStreamIndexForFfmpeg(mediaSource, subtitleStream);
if (streamIndex == -1)
{
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index ab8d5dd5b2..4799ed5410 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -26,6 +26,8 @@ namespace MediaBrowser.Model.Dlna
internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons;
internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported;
+ private const string ManifestContainers = "hls,applehttp,dash";
+
private readonly ILogger _logger;
private readonly ITranscoderSupport _transcoderSupport;
private static readonly string[] _supportedHlsVideoCodecs = ["h264", "hevc", "vp9", "av1"];
@@ -718,6 +720,14 @@ namespace MediaBrowser.Model.Dlna
isEligibleForDirectPlay = false;
}
+ // A manifest is not a byte stream, so it cannot be handed to the client as one. The variant
+ // and segment URIs inside it are relative to the origin and do not resolve against the
+ // Jellyfin url the client would fetch it from.
+ if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container))
+ {
+ isEligibleForDirectPlay = false;
+ }
+
if (bitrateLimitExceeded)
{
transcodeReasons = TranscodeReason.ContainerBitrateExceedsLimit;
diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs
index f057714bea..67af843626 100644
--- a/MediaBrowser.Model/Entities/MediaStream.cs
+++ b/MediaBrowser.Model/Entities/MediaStream.cs
@@ -810,6 +810,11 @@ namespace MediaBrowser.Model.Entities
return (VideoRange.Unknown, VideoRangeType.Unknown);
}
+ var isPq = string.Equals(ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase);
+ var isHlg = string.Equals(ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase);
+ // Invalid DV only retains HDR when the base layer explicitly signals PQ or HLG.
+ var baseVideoRange = isPq || isHlg ? VideoRange.HDR : VideoRange.SDR;
+
var codecTag = CodecTag;
var dvProfile = DvProfile;
var rpuPresentFlag = RpuPresentFlag == 1;
@@ -834,7 +839,7 @@ namespace MediaBrowser.Model.Entities
4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG),
2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR),
// Out of Dolby Spec files should be marked as invalid
- _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid)
+ _ => (baseVideoRange, VideoRangeType.DOVIInvalid)
},
7 => (VideoRange.HDR, VideoRangeType.DOVIWithEL),
10 => dvBlCompatId switch
@@ -844,11 +849,26 @@ namespace MediaBrowser.Model.Entities
2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR),
4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG),
// Out of Dolby Spec files should be marked as invalid
- _ => (VideoRange.HDR, VideoRangeType.DOVIInvalid)
+ _ => (baseVideoRange, VideoRangeType.DOVIInvalid)
},
_ => (VideoRange.SDR, VideoRangeType.SDR)
};
+ var expectedTransfer = dvRangeSet.Item2 switch
+ {
+ VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithEL => "smpte2084",
+ VideoRangeType.DOVIWithHLG => "arib-std-b67",
+ _ => null
+ };
+
+ if (expectedTransfer is not null
+ && (!string.Equals(ColorSpace, "bt2020nc", StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(ColorTransfer, expectedTransfer, StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(ColorPrimaries, "bt2020", StringComparison.OrdinalIgnoreCase)))
+ {
+ return (baseVideoRange, VideoRangeType.DOVIInvalid);
+ }
+
if (Hdr10PlusPresentFlag == true)
{
return dvRangeSet.Item2 switch
@@ -862,13 +882,11 @@ namespace MediaBrowser.Model.Entities
return dvRangeSet;
}
- var colorTransfer = ColorTransfer;
-
- if (string.Equals(colorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase))
+ if (isPq)
{
return Hdr10PlusPresentFlag == true ? (VideoRange.HDR, VideoRangeType.HDR10Plus) : (VideoRange.HDR, VideoRangeType.HDR10);
}
- else if (string.Equals(colorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase))
+ else if (isHlg)
{
return (VideoRange.HDR, VideoRangeType.HLG);
}
diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj
index 2dddd39ef4..83b44e8b2d 100644
--- a/MediaBrowser.Model/MediaBrowser.Model.csproj
+++ b/MediaBrowser.Model/MediaBrowser.Model.csproj
@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Model</PackageId>
- <VersionPrefix>12.0.0</VersionPrefix>
+ <VersionPrefix>13.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
index a06de95fce..61615b288a 100644
--- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
+++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs
@@ -54,9 +54,10 @@ public class ComicBookInfoProvider : IComicProvider
var archive = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, false, null, cancellationToken).ConfigureAwait(false);
await using (archive.ConfigureAwait(false))
{
- if (archive.Comment is null)
+ // ZipArchive.Comment is an empty string, not null, when the archive has no comment
+ if (string.IsNullOrWhiteSpace(archive.Comment))
{
- _logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path);
+ _logger.LogDebug("missing ComicBookInfo in archive comment: {Path}", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
@@ -71,6 +72,12 @@ public class ComicBookInfoProvider : IComicProvider
}
}
}
+ catch (JsonException ex)
+ {
+ // the archive comment is not reserved for ComicBookInfo, so any other content is not an error
+ _logger.LogDebug("archive comment is not valid ComicBookInfo metadata: {Path}: {Message}", info.Path, ex.Message);
+ return new MetadataResult<Book> { HasMetadata = false };
+ }
catch (Exception ex)
{
_logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path);
diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs
index af31e373ef..a19262c3a7 100644
--- a/MediaBrowser.Providers/Lyric/LyricManager.cs
+++ b/MediaBrowser.Providers/Lyric/LyricManager.cs
@@ -32,6 +32,7 @@ public class LyricManager : ILyricManager
private readonly IFileSystem _fileSystem;
private readonly ILibraryMonitor _libraryMonitor;
private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly IDirectoryService _directoryService;
private readonly ILyricProvider[] _lyricProviders;
private readonly ILyricParser[] _lyricParsers;
@@ -43,6 +44,7 @@ public class LyricManager : ILyricManager
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
/// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
/// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
public LyricManager(
@@ -50,6 +52,7 @@ public class LyricManager : ILyricManager
IFileSystem fileSystem,
ILibraryMonitor libraryMonitor,
IMediaSourceManager mediaSourceManager,
+ IDirectoryService directoryService,
IEnumerable<ILyricProvider> lyricProviders,
IEnumerable<ILyricParser> lyricParsers)
{
@@ -57,6 +60,7 @@ public class LyricManager : ILyricManager
_fileSystem = fileSystem;
_libraryMonitor = libraryMonitor;
_mediaSourceManager = mediaSourceManager;
+ _directoryService = directoryService;
_lyricProviders = lyricProviders
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
.ToArray();
@@ -250,6 +254,8 @@ public class LyricManager : ILyricManager
{
_libraryMonitor.ReportFileSystemChangeComplete(path, false);
}
+
+ _directoryService.Invalidate(path);
}
return audio.RefreshMetadata(CancellationToken.None);
@@ -446,6 +452,8 @@ public class LyricManager : ILyricManager
await stream.CopyToAsync(fs).ConfigureAwait(false);
}
+ _directoryService.Invalidate(savePath);
+
return;
}
catch (Exception ex)
diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
index 727f481b65..ce5468c5c4 100644
--- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs
+++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
@@ -254,6 +254,7 @@ namespace MediaBrowser.Providers.Manager
}
catch (Exception ex)
{
+ result.Failures++;
result.ErrorMessage = ex.Message;
_logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name);
}
@@ -338,6 +339,7 @@ namespace MediaBrowser.Providers.Manager
}
catch (Exception ex)
{
+ result.Failures++;
result.ErrorMessage = ex.Message;
_logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, item.Path ?? item.Name);
}
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index fe5285bf65..05c542337e 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -192,9 +192,13 @@ namespace MediaBrowser.Providers.Manager
}
}
- // Next run remote image providers, but only if local image providers didn't throw an exception
- if (!localImagesFailed && refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly)
+ if (localImagesFailed)
{
+ hasRefreshedImages = false;
+ }
+ else if (refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly)
+ {
+ // Next run remote image providers, now that local image providers didn't throw
var providers = GetNonLocalImageProviders(item, allImageProviders, refreshOptions).ToList();
if (providers.Count > 0)
@@ -937,6 +941,7 @@ namespace MediaBrowser.Providers.Manager
}
catch (Exception ex)
{
+ refreshResult.Failures++;
refreshResult.ErrorMessage = ex.Message;
Logger.LogError(ex, "Error in {Provider} for {Item}", provider.Name, logName);
}
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index fbd9e5435e..8387f24bc9 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -76,7 +76,8 @@ namespace MediaBrowser.Providers.Manager
/// <summary>
/// Cache for ordered metadata providers per library/item type combination.
- /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata).
+ /// Key: (LibraryPath, ItemTypeName, IncludeDisabled, ForceEnableInternetMetadata), where
+ /// LibraryPath is the collection folder path the library options are stored against.
/// Value: Array of ordered metadata providers (before per-item filtering).
/// </summary>
private readonly ConcurrentDictionary<MetadataProviderCacheKey, IMetadataProvider[]> _metadataProviderCache = new();
@@ -136,6 +137,7 @@ namespace MediaBrowser.Providers.Manager
_similarItemsManager = similarItemsManager;
CollectionFolder.LibraryOptionsUpdated += OnLibraryOptionsUpdated;
+ _configurationManager.ConfigurationUpdated += OnConfigurationUpdated;
}
/// <inheritdoc/>
@@ -476,15 +478,15 @@ namespace MediaBrowser.Providers.Manager
return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, includeDisabled, false, libraryPath);
}
- private static string GetLibraryPathForItem(BaseItem item)
+ private string GetLibraryPathForItem(BaseItem item)
{
if (item is CollectionFolder collectionFolder)
{
return collectionFolder.Path ?? string.Empty;
}
- var topParent = item.GetTopParent();
- return topParent?.Path ?? string.Empty;
+ return _libraryManager.GetCollectionFolders(item)
+ .Find(folder => folder is CollectionFolder)?.Path ?? string.Empty;
}
/// <inheritdoc />
@@ -1143,16 +1145,21 @@ namespace MediaBrowser.Providers.Manager
return;
}
- _refreshQueue.Enqueue((itemId, options), priority);
-
+ // PriorityQueue is not thread safe and the processor dequeues concurrently, so every
+ // touch of the queue takes the lock.
lock (_refreshQueueLock)
{
- if (!_isProcessingRefreshQueue)
+ _refreshQueue.Enqueue((itemId, options), priority);
+
+ if (_isProcessingRefreshQueue)
{
- _isProcessingRefreshQueue = true;
- Task.Run(StartProcessingRefreshQueue);
+ return;
}
+
+ _isProcessingRefreshQueue = true;
}
+
+ Task.Run(StartProcessingRefreshQueue);
}
private async Task StartProcessingRefreshQueue()
@@ -1161,17 +1168,33 @@ namespace MediaBrowser.Providers.Manager
if (_disposed)
{
+ lock (_refreshQueueLock)
+ {
+ _isProcessingRefreshQueue = false;
+ }
+
return;
}
var cancellationToken = _disposeCancellationTokenSource.Token;
libraryManager.ClearIgnoreRuleCache();
- while (_refreshQueue.TryDequeue(out var refreshItem, out _))
+
+ while (true)
{
- if (_disposed)
+ (Guid ItemId, MetadataRefreshOptions RefreshOptions) refreshItem;
+
+ // Dequeueing and standing down happen under one lock, otherwise a refresh queued
+ // just after the queue ran dry would see a processor that has already stopped.
+ lock (_refreshQueueLock)
{
- return;
+ if (_disposed
+ || cancellationToken.IsCancellationRequested
+ || !_refreshQueue.TryDequeue(out refreshItem, out _))
+ {
+ _isProcessingRefreshQueue = false;
+ break;
+ }
}
try
@@ -1188,19 +1211,21 @@ namespace MediaBrowser.Providers.Manager
await task.ConfigureAwait(false);
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
- break;
+ // Shutting down: the next pass sees the token and stands the processor down.
+ continue;
}
catch (Exception ex)
{
+ // Includes a provider that cancelled for its own reasons, such as an HTTP
+ // timeout, which must not stop the queue draining.
_logger.LogError(ex, "Error refreshing item");
}
}
- lock (_refreshQueueLock)
+ if (!_disposed)
{
- _isProcessingRefreshQueue = false;
libraryManager.ClearIgnoreRuleCache();
}
}
@@ -1291,6 +1316,7 @@ namespace MediaBrowser.Providers.Manager
if (disposing)
{
CollectionFolder.LibraryOptionsUpdated -= OnLibraryOptionsUpdated;
+ _configurationManager.ConfigurationUpdated -= OnConfigurationUpdated;
if (!_disposeCancellationTokenSource.IsCancellationRequested)
{
@@ -1318,6 +1344,11 @@ namespace MediaBrowser.Providers.Manager
_logger.LogDebug("Invalidated metadata provider cache for library: {LibraryPath}", e.LibraryPath);
}
+ private void OnConfigurationUpdated(object? sender, EventArgs e)
+ {
+ ClearMetadataProviderCache();
+ }
+
internal void ClearMetadataProviderCache()
{
_metadataProviderCache.Clear();
@@ -1327,7 +1358,7 @@ namespace MediaBrowser.Providers.Manager
/// <summary>
/// Cache key for metadata provider lookups.
/// </summary>
- /// <param name="LibraryPath">The library path for the collection folder.</param>
+ /// <param name="LibraryPath">The path of the collection folder providing the library options.</param>
/// <param name="ItemTypeName">The item type name.</param>
/// <param name="IncludeDisabled">Whether to include disabled providers.</param>
/// <param name="ForceEnableInternetMetadata">Whether internet metadata is force-enabled.</param>
diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
index 2b0f480b1c..7c3e1867ef 100644
--- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj
+++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj
@@ -26,6 +26,7 @@
<PackageReference Include="SharpCompress" />
<PackageReference Include="z440.atl.core" />
<PackageReference Include="TMDbLib" />
+ <PackageReference Include="UTF.Unknown" />
</ItemGroup>
<PropertyGroup>
diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
index 6f9d5f19da..ecb6e5d990 100644
--- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
+++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
@@ -231,10 +231,28 @@ namespace MediaBrowser.Providers.MediaInfo
return Array.Empty<ExternalPathParserResult>();
}
+ // VobSub .sub payloads only carry per-track language metadata when read via
+ // their paired .idx file, so probe the .idx instead and skip the .sub. Pairing
+ // requires the same directory (ffprobe can't resolve a split pair) and an
+ // ordinal comparison (ffprobe matches the .sub by exact case on case-sensitive
+ // filesystems, so a looser match could suppress a .sub with no working .idx).
+ // An .idx file with no paired .sub cannot be probed at all, so it is left out
+ // entirely rather than surfaced (which would otherwise fail every probe and,
+ // since the .idx would keep "existing" from Jellyfin's point of view, prevent
+ // stale subtitle stream metadata from ever being cleared once the .sub is gone).
+ HashSet<string>? pairedVobSubKeys = _type == DlnaProfileType.Subtitle
+ ? GetPairedVobSubKeys(files)
+ : null;
+
var externalPathInfos = new List<ExternalPathParserResult>();
ReadOnlySpan<char> prefix = video.FileNameWithoutExtension;
foreach (var file in files)
{
+ if (IsSuppressedVobSubFile(file, pairedVobSubKeys))
+ {
+ continue;
+ }
+
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
if (fileNameWithoutExtension.Length >= prefix.Length
&& prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
@@ -305,6 +323,77 @@ namespace MediaBrowser.Providers.MediaInfo
}
/// <summary>
+ /// Determines whether a candidate file is part of a VobSub .idx/.sub pair that
+ /// should be resolved to only its .idx file, or an .idx file with no paired .sub
+ /// that cannot be probed at all.
+ /// </summary>
+ /// <param name="file">The full path to the candidate file.</param>
+ /// <param name="pairedVobSubKeys">The set of pairing keys with both an .idx and .sub present, or null if not applicable.</param>
+ /// <returns><c>true</c> if the file should be suppressed; otherwise, <c>false</c>.</returns>
+ private static bool IsSuppressedVobSubFile(string file, HashSet<string>? pairedVobSubKeys)
+ {
+ if (pairedVobSubKeys is null)
+ {
+ return false;
+ }
+
+ var extension = Path.GetExtension(file.AsSpan());
+ if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
+ {
+ // A paired .idx exists; probe it instead of the .sub payload.
+ return pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
+ }
+
+ if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
+ {
+ // Without its .sub payload, the .idx cannot be probed for any data.
+ return !pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Builds the set of directory+basename keys that have both an .idx and a .sub
+ /// file present, in a single pass over the candidate files.
+ /// </summary>
+ /// <param name="files">The candidate files to search.</param>
+ /// <returns>The set of pairing keys with both an .idx and .sub present.</returns>
+ private static HashSet<string> GetPairedVobSubKeys(IEnumerable<string> files)
+ {
+ var idxKeys = new HashSet<string>(StringComparer.Ordinal);
+ var subKeys = new HashSet<string>(StringComparer.Ordinal);
+ foreach (var file in files)
+ {
+ var extension = Path.GetExtension(file.AsSpan());
+ if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
+ {
+ idxKeys.Add(GetVobSubPairingKey(file));
+ }
+ else if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
+ {
+ subKeys.Add(GetVobSubPairingKey(file));
+ }
+ }
+
+ idxKeys.IntersectWith(subKeys);
+ return idxKeys;
+ }
+
+ /// <summary>
+ /// Builds a directory+basename key used to pair a VobSub .idx file with its .sub
+ /// payload only when both live in the same directory.
+ /// </summary>
+ /// <param name="file">The full path to the file.</param>
+ /// <returns>A key combining the containing directory and file name without extension.</returns>
+ private static string GetVobSubPairingKey(string file)
+ {
+ var directory = Path.GetDirectoryName(file) ?? string.Empty;
+ var baseName = Path.GetFileNameWithoutExtension(file);
+ return Path.Combine(directory, baseName);
+ }
+
+ /// <summary>
/// Returns the media info of the given file.
/// </summary>
/// <param name="path">The path to the file.</param>
diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
index 221c6bff5e..5d78cdd0be 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -142,6 +142,12 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
+ if (IsMissingMediaInfo(item))
+ {
+ _logger.LogDebug("Refreshing {ItemPath} because it has no media information.", item.Path);
+ return true;
+ }
+
if (video is not null
&& item.SupportsLocalMetadata
&& !video.IsPlaceHolder)
@@ -175,6 +181,25 @@ namespace MediaBrowser.Providers.MediaInfo
return false;
}
+ private static bool IsMissingMediaInfo(BaseItem item)
+ {
+ if (item.RunTimeTicks.HasValue
+ || item.TotalBitrate.HasValue
+ || item.IsVirtualItem
+ || item.IsShortcut
+ || !item.IsFileProtocol)
+ {
+ return false;
+ }
+
+ return item switch
+ {
+ Video video => !video.IsPlaceHolder && video.IsCompleteMedia,
+ Audio => true,
+ _ => false
+ };
+ }
+
/// <inheritdoc />
public Task<ItemUpdateType> FetchAsync(Episode item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
index 924fde4808..4952d03e8a 100644
--- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
+++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
@@ -15,6 +16,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
using PlaylistsNET.Content;
+using UtfUnknown;
namespace MediaBrowser.Providers.Playlists;
@@ -26,6 +28,11 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
IForcedProvider,
IHasItemChangeMonitor
{
+ /// <summary>
+ /// Minimum confidence required before a detected encoding is preferred over UTF-8.
+ /// </summary>
+ private const float MinimumEncodingConfidence = 0.5f;
+
private readonly IFileSystem _fileSystem;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<PlaylistItemsProvider> _logger;
@@ -136,23 +143,38 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots)
{
var content = new PlsContent();
- var playlist = content.GetFromStream(stream);
+ var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath));
return playlist.PlaylistEntries
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
.Where(i => i is not null);
}
- private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots)
+ internal IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots)
{
var content = new M3uContent();
- var playlist = content.GetFromStream(stream);
+ var playlist = content.GetFromStream(stream, DetectEncoding(stream, playlistPath));
return playlist.PlaylistEntries
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
.Where(i => i is not null);
}
+ private Encoding DetectEncoding(Stream stream, string playlistPath)
+ {
+ var detected = CharsetDetector.DetectFromStream(stream).Detected;
+ stream.Seek(0, SeekOrigin.Begin);
+
+ if (detected?.Encoding is null || detected.Confidence < MinimumEncodingConfidence)
+ {
+ _logger.LogDebug("Could not detect the encoding of playlist {Path}, assuming UTF-8", playlistPath);
+ return Encoding.UTF8;
+ }
+
+ _logger.LogDebug("Detected encoding {Encoding} for playlist {Path}", detected.Encoding.WebName, playlistPath);
+ return detected.Encoding;
+ }
+
private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots)
{
var content = new ZplContent();
@@ -191,7 +213,7 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
{
item = null;
string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath);
- if (!File.Exists(pathToCheck))
+ if (!File.Exists(pathToCheck) && !TryNormalizePath(ref pathToCheck))
{
return false;
}
@@ -208,6 +230,36 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
return false;
}
+ private static bool TryNormalizePath(ref string path)
+ {
+ foreach (var form in new[] { NormalizationForm.FormC, NormalizationForm.FormD })
+ {
+ string normalized;
+ try
+ {
+ if (path.IsNormalized(form))
+ {
+ continue;
+ }
+
+ normalized = path.Normalize(form);
+ }
+ catch (ArgumentException)
+ {
+ // The path is not valid Unicode, there is nothing to normalize.
+ return false;
+ }
+
+ if (File.Exists(normalized))
+ {
+ path = normalized;
+ return true;
+ }
+ }
+
+ return false;
+ }
+
/// <inheritdoc />
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
index ef952082da..9501da7ec6 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
@@ -163,12 +163,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
// Caller provides the filename with extension stripped and NOT the parsed filename
var parsedName = _libraryManager.ParseName(info.Name);
var cleanedName = TmdbUtils.CleanName(parsedName.Name);
+ var searchYear = info.Year ?? parsedName.Year ?? 0;
- var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
+ var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, searchYear, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
- if (searchResults?.Count > 0)
+ var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear);
+
+ if (match is not null)
{
- tmdbId = searchResults[0].Id;
+ tmdbId = match.Id;
}
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs
index 5206de78ce..6a3c72d2fa 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieSimilarProvider.cs
@@ -58,7 +58,7 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
}
var providerName = MetadataProvider.Tmdb.ToString();
- var page = 0;
+ var page = 1;
var totalPages = 1;
while (page <= totalPages && !cancellationToken.IsCancellationRequested)
@@ -67,12 +67,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
try
{
(pageResults, totalPages) = await _tmdbClientManager
- .GetMovieSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
+ .GetMovieRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Failed to get similar movies from TMDb for {TmdbId} page {Page}", tmdbId, page);
+ _logger.LogWarning(ex, "Failed to get recommended movies from TMDb for {TmdbId} page {Page}", tmdbId, page);
yield break;
}
@@ -81,12 +81,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
yield break;
}
- foreach (var similar in pageResults)
+ foreach (var recommendation in pageResults)
{
yield return new SimilarItemReference
{
ProviderName = providerName,
- ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture)
+ ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture)
};
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index 6163e20194..b6bb7aa013 100755
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -202,11 +202,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// Caller provides the filename with extension stripped and NOT the parsed filename
var parsedName = _libraryManager.ParseName(info.Name);
var cleanedName = TmdbUtils.CleanName(parsedName.Name);
- var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false);
+ var searchYear = info.Year ?? parsedName.Year ?? 0;
+ var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, searchYear, cancellationToken).ConfigureAwait(false);
- if (searchResults?.Count > 0)
+ var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear);
+
+ if (match is not null)
{
- tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture);
+ tmdbId = match.Id.ToString(CultureInfo.InvariantCulture);
}
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs
index c85718b993..40c7de05ad 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesSimilarProvider.cs
@@ -67,12 +67,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series>
try
{
(pageResults, totalPages) = await _tmdbClientManager
- .GetSeriesSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
+ .GetSeriesRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
- _logger.LogWarning(ex, "Failed to get similar TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page);
+ _logger.LogWarning(ex, "Failed to get recommended TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page);
yield break;
}
@@ -81,12 +81,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series>
yield break;
}
- foreach (var similar in pageResults)
+ foreach (var recommendation in pageResults)
{
yield return new SimilarItemReference
{
ProviderName = providerName,
- ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture)
+ ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture)
};
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index ccc714278d..d75ebae988 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -29,6 +29,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
// differ in weight by orders of magnitude.
private const int CacheSizeLimit = 100_000;
+ private static readonly Dictionary<string, string> ThumbnailSizes = new Dictionary<string, string>
+ {
+ { "Primary", "w500" },
+ { "Backdrop", "w780" },
+ { "Thumb", "w780" },
+ { "Logo", "w500" },
+ };
+
private readonly MemoryCache _memoryCache;
private readonly TMDbClient _tmDbClient;
@@ -326,7 +334,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
person = await _tmDbClient.GetPersonAsync(
personTmdbId,
TmdbUtils.NormalizeLanguage(language, countryCode),
- PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds,
+ PersonMethods.Images | PersonMethods.ExternalIds,
cancellationToken).ConfigureAwait(false);
if (person is not null)
@@ -508,19 +516,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
/// <summary>
- /// Gets a single page of similar movies for a movie from the TMDb API.
+ /// Gets a single page of recommended movies for a movie from the TMDb API.
/// </summary>
/// <param name="tmdbId">The TMDb id of the movie.</param>
/// <param name="page">The page number to fetch (1-based).</param>
/// <param name="language">The language for results.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>A tuple containing the list of similar movies and the total number of pages available.</returns>
- public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
+ /// <returns>A tuple containing the list of recommended movies and the total number of pages available.</returns>
+ public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
{
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
- .GetMovieSimilarAsync(tmdbId, language, page, cancellationToken)
+ .GetMovieRecommendationsAsync(tmdbId, language, page, cancellationToken)
.ConfigureAwait(false);
if (searchResults?.Results is null || searchResults.Results.Count == 0)
@@ -532,19 +540,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
/// <summary>
- /// Gets a single page of similar TV shows for a series from the TMDb API.
+ /// Gets a single page of recommended TV shows for a series from the TMDb API.
/// </summary>
/// <param name="tmdbId">The TMDb id of the TV show.</param>
/// <param name="page">The page number to fetch (1-based).</param>
/// <param name="language">The language for results.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>A tuple containing the list of similar TV shows and the total number of pages available.</returns>
- public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
+ /// <returns>A tuple containing the list of recommended TV shows and the total number of pages available.</returns>
+ public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
{
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
- .GetTvShowSimilarAsync(tmdbId, language, page, cancellationToken)
+ .GetTvShowRecommendationsAsync(tmdbId, language, page, cancellationToken)
.ConfigureAwait(false);
if (searchResults?.Results is null || searchResults.Results.Count == 0)
@@ -568,8 +576,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
return null;
}
- // Use "original" as default size if size is null or empty to prevent malformed URLs
- var imageSize = string.IsNullOrEmpty(size) ? "original" : size;
+ // Use the original size as default if size is null or empty to prevent malformed URLs
+ var imageSize = string.IsNullOrEmpty(size) ? TmdbUtils.OriginalImageSize : size;
return _tmDbClient.GetImageUrl(imageSize, path, true).ToString();
}
@@ -660,7 +668,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
private IEnumerable<RemoteImageInfo> ConvertToRemoteImageInfo(IReadOnlyList<ImageData> images, string? size, ImageType type, string requestLanguage)
{
// sizes provided are for original resolution, don't store them when downloading scaled images
- var scaleImage = !string.Equals(size, "original", StringComparison.OrdinalIgnoreCase);
+ var scaleImage = !TmdbUtils.IsOriginalImageSize(size);
for (var i = 0; i < images.Count; i++)
{
@@ -678,6 +686,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
yield return new RemoteImageInfo
{
Url = GetUrl(size, image.FilePath),
+ ThumbnailUrl = GetUrl(ThumbnailSizes.GetValueOrDefault(type.ToString(), string.Empty), image.FilePath),
CommunityRating = image.VoteAverage,
VoteCount = image.VoteCount,
Width = scaleImage ? null : image.Width,
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
index 44a2f7291e..f004251594 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
@@ -8,6 +8,7 @@ using System.Text.RegularExpressions;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
using TMDbLib.Objects.General;
+using TMDbLib.Objects.Search;
using TMDbLib.Objects.TvShows;
using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo;
@@ -34,6 +35,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
public const string ApiKey = "4219e299c89411838049ab0dab19ebd5";
/// <summary>
+ /// The image size representing the unscaled image as served by TMDb.
+ /// </summary>
+ public const string OriginalImageSize = "original";
+
+ private const int TitleExactScore = 8;
+ private const int TitlePrefixScore = 4;
+ private const int YearExactScore = 2;
+ private const int YearAdjacentScore = 1;
+
+ /// <summary>
/// The crew types to keep.
/// </summary>
public static readonly string[] WantedCrewTypes =
@@ -63,8 +74,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
"novel"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
- [GeneratedRegex(@"[\W_-[·]]+")]
- private static partial Regex NonWordRegex();
+ /// <summary>
+ /// Everything that is not a letter, a number or a combining mark separates two search terms. The
+ /// interpunct is kept because TMDb uses it inside titles such as "WALL·E", where it matches better
+ /// than a space does.
+ /// </summary>
+ [GeneratedRegex(@"[^\p{L}\p{N}\p{M}·]+")]
+ private static partial Regex NonSearchTermRegex();
+
+ /// <summary>
+ /// As <see cref="NonSearchTermRegex"/>, but the interpunct is a separator too, so a "WALL-E" folder
+ /// and the "WALL·E" title TMDb returns compare equal.
+ /// </summary>
+ [GeneratedRegex(@"[^\p{L}\p{N}\p{M}]+")]
+ private static partial Regex NonComparableRegex();
/// <summary>
/// Gets the TMDb id of an item, if it has one TMDb can be queried with.
@@ -101,7 +124,140 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
public static string CleanName(string name)
{
// TMDb expects a space separated list of words make sure that is the case
- return NonWordRegex().Replace(name, " ");
+ return NonSearchTermRegex().Replace(name, " ").Trim();
+ }
+
+ /// <summary>
+ /// Reduces a title to the form used to compare a local name against a TMDb search result.
+ /// </summary>
+ /// <param name="title">The title to normalize.</param>
+ /// <returns>The normalized title, or an empty string if there was nothing to normalize.</returns>
+ public static string NormalizeTitle(string? title)
+ {
+ return string.IsNullOrEmpty(title)
+ ? string.Empty
+ : NonComparableRegex().Replace(title, " ").Trim().ToLowerInvariant();
+ }
+
+ /// <summary>
+ /// Picks the movie search result that best matches the name and year an item was looked up by.
+ /// </summary>
+ /// <param name="results">The search results, in the order TMDb returned them.</param>
+ /// <param name="name">The parsed name of the local item.</param>
+ /// <param name="year">The year of the local item, or 0 if it is unknown.</param>
+ /// <returns>The best match, or <c>null</c> if there were no results.</returns>
+ public static SearchMovie? FindBestMatch(IReadOnlyList<SearchMovie>? results, string? name, int year)
+ {
+ return FindBestMatch(
+ results,
+ name,
+ year,
+ static movie => movie.Title,
+ static movie => movie.OriginalTitle,
+ static movie => movie.ReleaseDate);
+ }
+
+ /// <summary>
+ /// Picks the series search result that best matches the name and year an item was looked up by.
+ /// </summary>
+ /// <param name="results">The search results, in the order TMDb returned them.</param>
+ /// <param name="name">The parsed name of the local item.</param>
+ /// <param name="year">The year of the local item, or 0 if it is unknown.</param>
+ /// <returns>The best match, or <c>null</c> if there were no results.</returns>
+ public static SearchTv? FindBestMatch(IReadOnlyList<SearchTv>? results, string? name, int year)
+ {
+ return FindBestMatch(
+ results,
+ name,
+ year,
+ static series => series.Name,
+ static series => series.OriginalName,
+ static series => series.FirstAirDate);
+ }
+
+ /// <summary>
+ /// Picks the search result that best matches the name and year an item was looked up by.
+ /// </summary>
+ /// <remarks>
+ /// TMDb's year parameter only nudges relevance, it does not filter, so the first hit is regularly a
+ /// different film or show that happens to share the title - searching for "Mulan" with year 2020
+ /// returns the 1998 film first. A title that matches outranks one that does not, and the year only
+ /// separates candidates that are otherwise equally good. When nothing matches at all TMDb's own
+ /// ordering is kept, so a name that needs fuzzy matching, such as "A Christmas No. 1" for
+ /// "A Christmas Number One", still resolves.
+ /// </remarks>
+ private static T? FindBestMatch<T>(
+ IReadOnlyList<T>? results,
+ string? name,
+ int year,
+ Func<T, string?> titleSelector,
+ Func<T, string?> originalTitleSelector,
+ Func<T, DateTime?> releaseDateSelector)
+ where T : class
+ {
+ if (results is null || results.Count == 0)
+ {
+ return null;
+ }
+
+ var normalizedName = NormalizeTitle(name);
+ if (normalizedName.Length == 0)
+ {
+ return results[0];
+ }
+
+ var best = results[0];
+ var bestScore = 0;
+
+ foreach (var result in results)
+ {
+ var score = Math.Max(
+ ScoreTitle(normalizedName, titleSelector(result)),
+ ScoreTitle(normalizedName, originalTitleSelector(result)))
+ + ScoreYear(year, releaseDateSelector(result)?.Year);
+
+ // Strictly greater, so ties keep the earlier, more relevant result.
+ if (score > bestScore)
+ {
+ bestScore = score;
+ best = result;
+ }
+ }
+
+ return best;
+ }
+
+ private static int ScoreTitle(string normalizedName, string? title)
+ {
+ var normalizedTitle = NormalizeTitle(title);
+
+ if (string.Equals(normalizedName, normalizedTitle, StringComparison.Ordinal))
+ {
+ return TitleExactScore;
+ }
+
+ // Whole words only, otherwise "Wall" half matches "Wall Street".
+ return normalizedTitle.Length > normalizedName.Length
+ && normalizedTitle[normalizedName.Length] == ' '
+ && normalizedTitle.StartsWith(normalizedName, StringComparison.Ordinal)
+ ? TitlePrefixScore
+ : 0;
+ }
+
+ private static int ScoreYear(int year, int? resultYear)
+ {
+ if (year <= 0 || resultYear is not int candidateYear)
+ {
+ return 0;
+ }
+
+ return Math.Abs(candidateYear - year) switch
+ {
+ 0 => YearExactScore,
+ // Regional release dates routinely straddle a new year.
+ 1 => YearAdjacentScore,
+ _ => 0
+ };
}
/// <summary>
@@ -335,6 +491,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
/// <summary>
+ /// Determines whether the configured image size fetches the image at its original resolution.
+ /// An unset size falls back to <see cref="OriginalImageSize"/>, see TmdbClientManager.GetUrl.
+ /// </summary>
+ /// <param name="size">The configured image size.</param>
+ /// <returns><c>true</c> if the original image is fetched; otherwise, <c>false</c>.</returns>
+ public static bool IsOriginalImageSize(string? size)
+ => string.IsNullOrEmpty(size) || string.Equals(size, OriginalImageSize, StringComparison.OrdinalIgnoreCase);
+
+ /// <summary>
/// Combines the metadata country code and the parental rating from the API into the value we store in our database.
/// </summary>
/// <param name="countryCode">The ISO 3166-1 country code of the rating country.</param>
diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
index c3458d4b2a..cd9dda21a0 100644
--- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
+++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
@@ -33,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles
private readonly ILibraryMonitor _monitor;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly ILocalizationManager _localization;
+ private readonly IDirectoryService _directoryService;
private readonly HashSet<string> _allowedSubtitleFormats;
private readonly ISubtitleProvider[] _subtitleProviders;
@@ -43,6 +44,7 @@ namespace MediaBrowser.Providers.Subtitles
ILibraryMonitor monitor,
IMediaSourceManager mediaSourceManager,
ILocalizationManager localizationManager,
+ IDirectoryService directoryService,
IEnumerable<ISubtitleProvider> subtitleProviders,
NamingOptions namingOptions)
{
@@ -51,6 +53,7 @@ namespace MediaBrowser.Providers.Subtitles
_monitor = monitor;
_mediaSourceManager = mediaSourceManager;
_localization = localizationManager;
+ _directoryService = directoryService;
_subtitleProviders = subtitleProviders
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
.ToArray();
@@ -281,6 +284,8 @@ namespace MediaBrowser.Providers.Subtitles
await stream.CopyToAsync(fs).ConfigureAwait(false);
}
+ _directoryService.Invalidate(path);
+
return;
}
else
@@ -395,6 +400,8 @@ namespace MediaBrowser.Providers.Subtitles
_monitor.ReportFileSystemChangeComplete(path, false);
}
+ _directoryService.Invalidate(path);
+
return item.RefreshMetadata(CancellationToken.None);
}
diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs
index 596ca8d201..f662ac2367 100644
--- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs
+++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs
@@ -44,6 +44,31 @@ public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo>
{
var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
+ // An episode cannot end before it starts.
+ if (item.IndexNumberEnd < item.IndexNumber)
+ {
+ Logger.LogWarning(
+ "Discarding episode range end {IndexNumberEnd} preceding episode number {IndexNumber} for {Path}",
+ item.IndexNumberEnd,
+ item.IndexNumber,
+ item.Path);
+
+ item.IndexNumberEnd = null;
+ updatedType |= ItemUpdateType.MetadataImport;
+ }
+ else if (item.IndexNumberEnd.HasValue && !item.IndexNumber.HasValue)
+ {
+ // Without a first episode the end does not describe a range. Promoting it to the episode number
+ // would invent an identity the metadata never supplied, so drop the orphaned value instead.
+ Logger.LogWarning(
+ "Discarding episode range end {IndexNumberEnd} without an episode number for {Path}",
+ item.IndexNumberEnd,
+ item.Path);
+
+ item.IndexNumberEnd = null;
+ updatedType |= ItemUpdateType.MetadataImport;
+ }
+
var seriesName = item.FindSeriesName();
if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal))
{
diff --git a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
index 19b1bbe7b6..c3e5c791c0 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
@@ -1,5 +1,7 @@
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Text;
using System.Threading;
using System.Xml;
@@ -44,41 +46,60 @@ namespace MediaBrowser.XbmcMetadata.Parsers
var xmlFile = File.ReadAllText(metadataFile);
- var srch = "</episodedetails>";
- var index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase);
-
- var xml = xmlFile;
+ // Split the nfo into its episodedetails blocks.
+ // This is needed because XBMC metadata uses multiple episodedetails blocks instead of an episodenumberend tag.
+ const string Srch = "</episodedetails>";
+ var blocks = new List<string>();
+ int index;
+ while ((index = xmlFile.IndexOf(Srch, StringComparison.OrdinalIgnoreCase)) != -1)
+ {
+ blocks.Add(xmlFile.Substring(0, index + Srch.Length));
+ xmlFile = xmlFile.Substring(index + Srch.Length);
+ }
- if (index != -1)
+ if (blocks.Count == 0)
{
- xml = xmlFile.Substring(0, index + srch.Length);
- xmlFile = xmlFile.Substring(index + srch.Length);
+ // No closing tag, let the xml reader deal with whatever is in the file
+ blocks.Add(xmlFile);
}
// These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions
try
{
- // Extract episode details from the first episodedetails block
- ReadEpisodeDetailsFromXml(item, xml, settings, cancellationToken);
+ if (blocks.Count == 1)
+ {
+ ReadEpisodeDetailsFromXml(item, blocks[0], settings, cancellationToken);
+ return;
+ }
+
+ // The blocks are not guaranteed to be written in ascending episode order, so parse them all
+ // and sort them before merging.
+ var episodes = blocks
+ .Select(block =>
+ {
+ var episode = new MetadataResult<Episode>()
+ {
+ Item = new Episode()
+ };
- // Extract the last episode number from nfo
- // Retrieves all additional episodedetails blocks from the rest of the nfo and concatenates the name, originalTitle and overview tags with the first episode
- // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag
+ ReadEpisodeDetailsFromXml(episode, block, settings, cancellationToken);
+
+ return (Xml: block, Result: episode);
+ })
+ .OrderBy(episode => episode.Result.Item.IndexNumber ?? int.MaxValue)
+ .ToList();
+
+ // Extract the details of the lowest numbered episode into the item that is returned to the caller
+ ReadEpisodeDetailsFromXml(item, episodes[0].Xml, settings, cancellationToken);
+
+ // Concatenate the name, originalTitle and overview tags of the remaining episodes with the first one
+ // and take the highest episode number as the last episode of the file
var name = new StringBuilder(item.Item.Name);
var originalTitle = new StringBuilder(item.Item.OriginalTitle);
var overview = new StringBuilder(item.Item.Overview);
- while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1)
+ for (var i = 1; i < episodes.Count; i++)
{
- xml = xmlFile.Substring(0, index + srch.Length);
- xmlFile = xmlFile.Substring(index + srch.Length);
-
- var additionalEpisode = new MetadataResult<Episode>()
- {
- Item = new Episode()
- };
-
- // Extract episode details from additional episodedetails block
- ReadEpisodeDetailsFromXml(additionalEpisode, xml, settings, cancellationToken);
+ var additionalEpisode = episodes[i].Result;
if (!string.IsNullOrEmpty(additionalEpisode.Item.Name))
{
diff --git a/SharedVersion.cs b/SharedVersion.cs
index 1d4a368aa2..0b2ab6a013 100644
--- a/SharedVersion.cs
+++ b/SharedVersion.cs
@@ -1,4 +1,4 @@
using System.Reflection;
-[assembly: AssemblyVersion("12.0.0")]
-[assembly: AssemblyFileVersion("12.0.0")]
+[assembly: AssemblyVersion("13.0.0")]
+[assembly: AssemblyFileVersion("13.0.0")]
diff --git a/bump_version b/bump_version
index 7525721c0c..0516a1806d 100755
--- a/bump_version
+++ b/bump_version
@@ -27,7 +27,6 @@ jellyfin_subprojects=(
MediaBrowser.Model/MediaBrowser.Model.csproj
Emby.Naming/Emby.Naming.csproj
src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
- src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj
)
issue_template_file="./.github/ISSUE_TEMPLATE/issue report.yml"
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
index b821476390..2ba3faf5f7 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs
@@ -22,6 +22,65 @@ public static class DescendantQueryHelper
b => !b.IsFolder && !b.IsVirtualItem;
/// <summary>
+ /// Gets the predicate identifying the items that stand on their own in a library. An alternate
+ /// version is a second file for the item that links it rather than an item beside it, and an owned
+ /// item belongs to its owner unless it is an extra (a trailer and the like, which carries both an
+ /// owner and an extra type). Nothing here turns on who is asking, so a count that applies it
+ /// answers the same with a user and without one.
+ /// </summary>
+ public static Expression<Func<BaseItemEntity, bool>> IsDistinctLibraryItem { get; } =
+ b => !b.PrimaryVersionId.HasValue && (!b.OwnerId.HasValue || b.ExtraType != null);
+
+ /// <summary>
+ /// Builds the predicate identifying the items a user has played, counting a multi-version item as
+ /// played when any of its alternate versions is. Mirrors the aggregation
+ /// <c>VersionResumeData.ApplyTo</c> performs on the played flag a single item reports, so that a
+ /// folder's unplayed count cannot disagree with the watched state its members render with.
+ /// </summary>
+ /// <param name="userId">The id of the user whose played state to test.</param>
+ /// <returns>The predicate matching the items that user has played.</returns>
+ public static Expression<Func<BaseItemEntity, bool>> IsPlayedBy(Guid userId) =>
+ b => b.UserData!.Any(u => u.UserId.Equals(userId) && u.Played)
+ || b.LinkedChildEntities!.Any(lc =>
+ (lc.ChildType == LinkedChildType.LocalAlternateVersion || lc.ChildType == LinkedChildType.LinkedAlternateVersion)
+ && lc.Child!.UserData!.Any(u => u.UserId.Equals(userId) && u.Played));
+
+ /// <summary>
+ /// Builds the projection pairing an item's id with <see cref="IsPlayedBy"/> evaluated on that same
+ /// row. A caller that needs the flag alongside the id composes it rather than testing membership of
+ /// the played set: as a sub-select the set is unbounded by whatever the caller joins it to, so the
+ /// database builds it from the whole table once per place it appears.
+ /// </summary>
+ /// <param name="userId">The id of the user whose played state to test.</param>
+ /// <returns>The projection of each item onto its id and that user's played state.</returns>
+ public static Expression<Func<BaseItemEntity, LeafPlayedState>> PlayedStateBy(Guid userId)
+ {
+ var played = IsPlayedBy(userId);
+ var item = played.Parameters[0];
+
+ // Named members, as the compiler emits for an anonymous type: without them the query provider
+ // cannot read a later `x.Id` back to the column it was built from and gives up translating.
+ return Expression.Lambda<Func<BaseItemEntity, LeafPlayedState>>(
+ Expression.New(
+ typeof(LeafPlayedState).GetConstructor([typeof(Guid), typeof(bool)])!,
+ [Expression.Property(item, nameof(BaseItemEntity.Id)), played.Body],
+ [typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Id))!, typeof(LeafPlayedState).GetProperty(nameof(LeafPlayedState.Played))!]),
+ item);
+ }
+
+ /// <summary>
+ /// Builds the negation of <see cref="IsPlayedBy"/>, so a caller filtering for unplayed items reads
+ /// the same definition of played as one filtering for played items.
+ /// </summary>
+ /// <param name="userId">The id of the user whose played state to test.</param>
+ /// <returns>The predicate matching the items that user has not played.</returns>
+ public static Expression<Func<BaseItemEntity, bool>> IsUnplayedBy(Guid userId)
+ {
+ var played = IsPlayedBy(userId);
+ return Expression.Lambda<Func<BaseItemEntity, bool>>(Expression.Not(played.Body), played.Parameters);
+ }
+
+ /// <summary>
/// Gets a queryable of all descendant IDs for a parent item.
/// Traverses AncestorIds and LinkedChildren to find all descendants.
/// </summary>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
index 27dbeaba6a..0a72287ba9 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
@@ -37,14 +37,18 @@ public interface IJellyfinDatabaseProvider
void ConfigureConventions(ModelConfigurationBuilder configurationBuilder);
/// <summary>
- /// If supported this should run any periodic maintaince tasks.
+ /// If supported this should run any periodic maintaince tasks, reclaiming unused space and refreshing the query
+ /// planner statistics. Also used after migrations have modified the database.
/// </summary>
/// <param name="cancellationToken">The token to abort the operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task RunScheduledOptimisation(CancellationToken cancellationToken);
/// <summary>
- /// If supported this should perform any actions that are required on stopping the jellyfin server.
+ /// If supported this should perform any actions that are required on stopping the jellyfin server. This runs
+ /// against a deadline imposed by the service manager, so unlike
+ /// <see cref="RunScheduledOptimisation(CancellationToken)"/> it should only do work whose cost does not grow with
+ /// the size of the database.
/// </summary>
/// <param name="cancellationToken">The token that will be used to abort the operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs
new file mode 100644
index 0000000000..0846013c45
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/LeafPlayedState.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace Jellyfin.Database.Implementations;
+
+/// <summary>
+/// An item's id paired with whether a given user has played it.
+/// </summary>
+/// <param name="Id">The id of the item.</param>
+/// <param name="Played">Whether the user has played the item.</param>
+public readonly record struct LeafPlayedState(Guid Id, bool Played);
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs
index a7f5e369ab..156f553fb3 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs
@@ -11,27 +11,19 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
-DELETE FROM BaseItems
- WHERE
- ParentId IS NOT NULL
- AND
- NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId);
-DELETE FROM BaseItems
- WHERE
- ParentId IS NOT NULL
- AND
- NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId);
-DELETE FROM BaseItems
- WHERE
- ParentId IS NOT NULL
- AND
- NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId);
-DELETE FROM BaseItems
- WHERE
- ParentId IS NOT NULL
- AND
- NOT EXISTS(SELECT 1 FROM BaseItems parent WHERE parent.Id = BaseItems.ParentId);
-""");
+ WITH RECURSIVE Orphan ("Id") AS (
+ SELECT Child."Id"
+ FROM "BaseItems" AS Child
+ WHERE Child."ParentId" IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM "BaseItems" AS Parent WHERE Parent."Id" = Child."ParentId")
+ UNION
+ SELECT Descendant."Id"
+ FROM "BaseItems" AS Descendant
+ INNER JOIN Orphan ON Descendant."ParentId" = Orphan."Id"
+ )
+ DELETE FROM "BaseItems" WHERE "Id" IN (SELECT "Id" FROM Orphan);
+ """);
+
migrationBuilder.AddForeignKey(
name: "FK_BaseItems_BaseItems_ParentId",
table: "BaseItems",
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs
index 4927b0e78d..379da0e9be 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260113203012_ChangeOwnerIdToGuid.cs
@@ -11,6 +11,61 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
+ migrationBuilder.Sql(
+ """
+ DROP TABLE IF EXISTS "OrphanedBaseItemIds";
+ CREATE TEMPORARY TABLE "OrphanedBaseItemIds" ("Id" TEXT NOT NULL PRIMARY KEY);
+
+ INSERT INTO "OrphanedBaseItemIds" ("Id")
+ WITH RECURSIVE Orphan ("Id") AS (
+ SELECT Child."Id"
+ FROM "BaseItems" AS Child
+ WHERE Child."ParentId" IS NOT NULL
+ AND NOT EXISTS (SELECT 1 FROM "BaseItems" AS Parent WHERE Parent."Id" = Child."ParentId")
+ UNION
+ SELECT Descendant."Id"
+ FROM "BaseItems" AS Descendant
+ INNER JOIN Orphan ON Descendant."ParentId" = Orphan."Id"
+ )
+ SELECT "Id" FROM Orphan;
+
+ -- Keep the play state of the doomed items the way ItemPersistenceService does when it
+ -- deletes an item: reattach it to the placeholder item instead of letting the
+ -- FK_UserData_BaseItems_ItemId cascade wipe it. The placeholder can only hold one row
+ -- per (UserId, CustomDataKey), so resolve collisions before repointing anything.
+ DELETE FROM "UserData"
+ WHERE "ItemId" = '00000000-0000-0000-0000-000000000001'
+ AND EXISTS (
+ SELECT 1
+ FROM "UserData" AS Doomed
+ INNER JOIN "OrphanedBaseItemIds" AS Orphan ON Orphan."Id" = Doomed."ItemId"
+ WHERE Doomed."UserId" = "UserData"."UserId"
+ AND Doomed."CustomDataKey" = "UserData"."CustomDataKey");
+
+ DELETE FROM "UserData"
+ WHERE "ItemId" IN (SELECT "Id" FROM "OrphanedBaseItemIds")
+ AND "rowid" NOT IN (
+ SELECT MIN("rowid")
+ FROM "UserData"
+ WHERE "ItemId" IN (SELECT "Id" FROM "OrphanedBaseItemIds")
+ GROUP BY "UserId", "CustomDataKey");
+
+ UPDATE "UserData"
+ SET "ItemId" = '00000000-0000-0000-0000-000000000001',
+ "RetentionDate" = datetime('now')
+ WHERE "ItemId" IN (SELECT "Id" FROM "OrphanedBaseItemIds");
+
+ -- FK_LinkedChildren_BaseItems_{ParentId,ChildId} are NO ACTION, so these rows have to
+ -- go by hand or the delete below fails on them.
+ DELETE FROM "LinkedChildren"
+ WHERE "ParentId" IN (SELECT "Id" FROM "OrphanedBaseItemIds")
+ OR "ChildId" IN (SELECT "Id" FROM "OrphanedBaseItemIds");
+
+ DELETE FROM "BaseItems" WHERE "Id" IN (SELECT "Id" FROM "OrphanedBaseItemIds");
+
+ DROP TABLE "OrphanedBaseItemIds";
+ """);
+
// Normalize OwnerId to uppercase GUID format
migrationBuilder.Sql(
@"UPDATE BaseItems
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs
index 3d4cf90441..2530e84af6 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs
@@ -11,8 +11,8 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
- migrationBuilder.Sql("DELETE FROM Permissions WHERE UserId IS NULL;");
- migrationBuilder.Sql("DELETE FROM Preferences WHERE UserId IS NULL;");
+ migrationBuilder.Sql("DELETE FROM Permissions WHERE UserId IS NULL OR UserId NOT IN (SELECT Id FROM Users);");
+ migrationBuilder.Sql("DELETE FROM Preferences WHERE UserId IS NULL OR UserId NOT IN (SELECT Id FROM Users);");
migrationBuilder.DropIndex(
name: "IX_Preferences_UserId_Kind",
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
index 8020fe1f93..3330b64b69 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Data.Common;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -103,17 +104,9 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
/// <inheritdoc/>
- public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
+ public Task RunScheduledOptimisation(CancellationToken cancellationToken)
{
- var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
- await using (context.ConfigureAwait(false))
- {
- await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
- await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
- await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
- await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
- _logger.LogInformation("jellyfin.db optimized successfully!");
- }
+ return OptimizeAsync(cancellationToken);
}
/// <inheritdoc/>
@@ -125,19 +118,109 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
/// <inheritdoc/>
public async Task RunShutdownTask(CancellationToken cancellationToken)
{
+ // Run before disposing the application. Only a checkpoint: stopping is on a deadline.
+
+ // Empty the pool first. Anything still parked in it can start reading again between here and the
+ // checkpoint, and a reader that holds the write-ahead log open is exactly what makes the truncation
+ // fail. Connections handed out already cannot be taken away, but they get disposed on return.
+ SqliteConnection.ClearAllPools();
+
+ try
+ {
+ if (DbContextFactory is not null)
+ {
+ var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ // A missed checkpoint only leaves a write-ahead log for the next start to replay, so never fail the
+ // shutdown over this.
+ _logger.LogError(ex, "Error while checkpointing jellyfin.db");
+ }
+
+ // The checkpointing connection went back into the pool, so retire that one as well.
+ SqliteConnection.ClearAllPools();
+ }
+
+ private async Task OptimizeAsync(CancellationToken cancellationToken)
+ {
if (DbContextFactory is null)
{
return;
}
- // Run before disposing the application
var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
- await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
+ await context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ long? tempStore;
+ long? analysisLimit;
+ var pragmaCommand = context.Database.GetDbConnection().CreateCommand();
+ await using (pragmaCommand.ConfigureAwait(false))
+ {
+ pragmaCommand.CommandText = "PRAGMA temp_store";
+ tempStore = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false);
+ pragmaCommand.CommandText = "PRAGMA analysis_limit";
+ analysisLimit = await ReadPragmaValueAsync(pragmaCommand, cancellationToken).ConfigureAwait(false);
+ }
+
+ await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
+
+ _logger.LogDebug(
+ "Rebuilding jellyfin.db on disk, scratch space goes to {TempDirectory}",
+ Environment.GetEnvironmentVariable("SQLITE_TMPDIR") ?? "SQLite's default temporary directory");
+ await context.Database.ExecuteSqlRawAsync("PRAGMA temp_store=1", cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ // The connection goes back to the pool, so hand it over the way it was handed to us.
+ if (tempStore is not null)
+ {
+ await context.Database.ExecuteSqlRawAsync(
+ FormattableString.Invariant($"PRAGMA temp_store={tempStore.Value}"),
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+
+ await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (analysisLimit is not null)
+ {
+ await context.Database.ExecuteSqlRawAsync(
+ FormattableString.Invariant($"PRAGMA analysis_limit={analysisLimit.Value}"),
+ CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+
+ await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("jellyfin.db optimized successfully!");
+ }
+ finally
+ {
+ await context.Database.CloseConnectionAsync().ConfigureAwait(false);
+ }
}
+ }
- SqliteConnection.ClearAllPools();
+ private static async Task<long?> ReadPragmaValueAsync(DbCommand command, CancellationToken cancellationToken)
+ {
+ var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+ return value is null or DBNull ? null : Convert.ToInt64(value, CultureInfo.InvariantCulture);
}
/// <inheritdoc/>
@@ -147,16 +230,31 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
/// <inheritdoc />
- public Task<string> MigrationBackupFast(CancellationToken cancellationToken)
+ public async Task<string> MigrationBackupFast(CancellationToken cancellationToken)
{
- var key = DateTime.UtcNow.ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture);
var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db");
- var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName);
- Directory.CreateDirectory(backupFile);
+ var backupFolder = Path.Combine(_applicationPaths.DataPath, BackupFolderName);
+ Directory.CreateDirectory(backupFolder);
+
+ if (DbContextFactory is not null && File.Exists(path))
+ {
+ var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ var key = DateTime.UtcNow.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
+ var backupFile = Path.Combine(backupFolder, $"{key}_jellyfin.db");
+ for (var attempt = 1; File.Exists(backupFile); attempt++)
+ {
+ key = string.Create(CultureInfo.InvariantCulture, $"{DateTime.UtcNow:yyyyMMddHHmmss}_{attempt}");
+ backupFile = Path.Combine(backupFolder, $"{key}_jellyfin.db");
+ }
- backupFile = Path.Combine(backupFile, $"{key}_jellyfin.db");
File.Copy(path, backupFile);
- return Task.FromResult(key);
+ return key;
}
/// <inheritdoc />
@@ -173,10 +271,55 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
return Task.CompletedTask;
}
+ if (!TryRetireWriteAheadLog(path))
+ {
+ _logger.LogCritical(
+ "Refusing to restore jellyfin.db: the write-ahead log at {WriteAheadLog} could not be retired, which "
+ + "means the database is still open and replacing it now would silently bring back the data this "
+ + "rollback is undoing. Stop the server and copy {Backup} over {Path} by hand.",
+ path + "-wal",
+ backupFile,
+ path);
+ return Task.CompletedTask;
+ }
+
File.Copy(backupFile, path, true);
+
return Task.CompletedTask;
}
+ private bool TryRetireWriteAheadLog(string path)
+ {
+ var writeAheadLogPath = path + "-wal";
+ if (!File.Exists(path) || !File.Exists(writeAheadLogPath))
+ {
+ return true;
+ }
+
+ try
+ {
+ var connectionString = new SqliteConnectionStringBuilder
+ {
+ DataSource = path,
+ Mode = SqliteOpenMode.ReadWrite,
+ Pooling = false
+ }.ToString();
+
+ using var connection = new SqliteConnection(connectionString);
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)";
+ command.ExecuteNonQuery();
+ }
+ catch (SqliteException ex)
+ {
+ // Either something else holds the database or it is too damaged to open. The check below covers both.
+ _logger.LogError(ex, "Could not open jellyfin.db to retire its write-ahead log");
+ }
+
+ return !File.Exists(writeAheadLogPath);
+ }
+
/// <inheritdoc />
public Task DeleteBackup(string key)
{
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index 3e353db8de..b8d40614d2 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -1,8 +1,10 @@
using System;
+using System.Buffers;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Runtime.InteropServices;
using BlurHashSharp.SkiaSharp;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
@@ -22,22 +24,15 @@ namespace Jellyfin.Drawing.Skia;
public class SkiaEncoder : IImageEncoder
{
private const string SvgFormat = "svg";
+
+ // The light sharpening kernel applied after resizing, see ResizeImage.
+ private const float SharpenCenterWeight = 1.4f;
+ private const float SharpenNeighborWeight = -0.1f;
+
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
private readonly ILogger<SkiaEncoder> _logger;
private readonly IApplicationPaths _appPaths;
private static readonly SKTypeface?[] _typefaces = InitializeTypefaces();
- private static readonly SKImageFilter _imageFilter = SKImageFilter.CreateMatrixConvolution(
- new SKSizeI(3, 3),
- [
- 0, -.1f, 0,
- -.1f, 1.4f, -.1f,
- 0, -.1f, 0
- ],
- 1f,
- 0f,
- new SKPointI(1, 1),
- SKShaderTileMode.Clamp,
- true);
/// <summary>
/// The default sampling options, equivalent to old high quality filter settings when upscaling.
@@ -561,8 +556,15 @@ public class SkiaEncoder : IImageEncoder
/// <returns>The resized image.</returns>
internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false)
{
- using var surface = SKSurface.Create(targetInfo);
- using var canvas = surface.Canvas;
+ if (source.Width == targetInfo.Width && source.Height == targetInfo.Height)
+ {
+ return SKImage.FromBitmap(source);
+ }
+
+ var isDownscale = source.Width > targetInfo.Width || source.Height > targetInfo.Height;
+
+ using var target = new SKBitmap(targetInfo);
+ using var canvas = new SKCanvas(target);
using var paint = new SKPaint();
paint.IsAntialias = isAntialias;
paint.IsDither = isDither;
@@ -570,11 +572,10 @@ public class SkiaEncoder : IImageEncoder
// Historically, kHigh implied cubic filtering, but only when upsampling.
// If specified kHigh, and were down-sampling, Skia used to switch back to kMedium (bilinear filtering plus mipmaps).
// With current skia API, passing Mitchell cubic when down-sampling will cause serious quality degradation.
- var samplingOptions = source.Width > targetInfo.Width || source.Height > targetInfo.Height
+ var samplingOptions = isDownscale
? DefaultSamplingOptions
: UpscaleSamplingOptions;
- paint.ImageFilter = _imageFilter;
canvas.DrawBitmap(
source,
SKRect.Create(0, 0, source.Width, source.Height),
@@ -582,7 +583,78 @@ public class SkiaEncoder : IImageEncoder
samplingOptions,
paint);
- return surface.Snapshot();
+ if (isDownscale)
+ {
+ SharpenInPlace(target);
+ }
+
+ return SKImage.FromBitmap(target);
+ }
+
+ /// <summary>
+ /// Applies the light 3x3 sharpening kernel to the bitmap in place.
+ ///
+ /// This is equivalent to the SKImageFilter.CreateMatrixConvolution paint filter that
+ /// was previously part of the resize draw call. Since the SkiaSharp 3 update that
+ /// filter no longer has a fast CPU path and takes multiple seconds per image on the
+ /// software rasterizer, so the same kernel is applied directly instead.
+ /// </summary>
+ /// <param name="bitmap">The bitmap to sharpen. Must use a color type with four bytes per pixel; other color types are returned unchanged.</param>
+ internal static void SharpenInPlace(SKBitmap bitmap)
+ {
+ if (bitmap.BytesPerPixel != 4)
+ {
+ return;
+ }
+
+ var width = bitmap.Width;
+ var height = bitmap.Height;
+ var stride = bitmap.RowBytes;
+ var pixels = bitmap.GetPixels();
+ if (width == 0 || height == 0 || pixels == IntPtr.Zero)
+ {
+ return;
+ }
+
+ var length = stride * height;
+ var source = ArrayPool<byte>.Shared.Rent(length);
+ var result = ArrayPool<byte>.Shared.Rent(length);
+ try
+ {
+ Marshal.Copy(pixels, source, 0, length);
+
+ for (var y = 0; y < height; y++)
+ {
+ // The kernel clamps at the edges: out-of-bounds taps reuse the edge pixel.
+ var row = y * stride;
+ var up = y == 0 ? row : row - stride;
+ var down = y == height - 1 ? row : row + stride;
+
+ for (var x = 0; x < width; x++)
+ {
+ var col = x * 4;
+ var left = x == 0 ? col : col - 4;
+ var right = x == width - 1 ? col : col + 4;
+
+ for (var channel = 0; channel < 4; channel++)
+ {
+ var value = (SharpenCenterWeight * source[row + col + channel])
+ + (SharpenNeighborWeight * (source[up + col + channel]
+ + source[down + col + channel]
+ + source[row + left + channel]
+ + source[row + right + channel]));
+ result[row + col + channel] = (byte)Math.Clamp((int)(value + 0.5f), 0, 255);
+ }
+ }
+ }
+
+ Marshal.Copy(result, 0, pixels, length);
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(source);
+ ArrayPool<byte>.Shared.Return(result);
+ }
}
/// <inheritdoc/>
diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
index 5518d9b954..bdffecdc8a 100644
--- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
+++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
@@ -15,7 +15,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Extensions</PackageId>
- <VersionPrefix>12.0.0</VersionPrefix>
+ <VersionPrefix>13.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
index ed02fe6a1d..256a652fd2 100644
--- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
+++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
@@ -372,9 +372,7 @@ namespace Jellyfin.LiveTv.Channels
{
IEnumerable<MediaSourceInfo> results = GetSavedMediaSources(item);
- return results
- .Select(i => NormalizeMediaSource(item, i))
- .ToList();
+ return NormalizeMediaSources(item, results);
}
/// <summary>
@@ -400,9 +398,7 @@ namespace Jellyfin.LiveTv.Channels
results = Enumerable.Empty<MediaSourceInfo>();
}
- return results
- .Select(i => NormalizeMediaSource(item, i))
- .ToList();
+ return NormalizeMediaSources(item, results);
}
private async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaSourcesInternal(IRequiresMediaInfoCallback channel, string id, CancellationToken cancellationToken)
@@ -420,11 +416,36 @@ namespace Jellyfin.LiveTv.Channels
return list;
}
- private static MediaSourceInfo NormalizeMediaSource(BaseItem item, MediaSourceInfo info)
+ private static IReadOnlyList<MediaSourceInfo> NormalizeMediaSources(BaseItem item, IEnumerable<MediaSourceInfo> infos)
{
- info.RunTimeTicks ??= item.RunTimeTicks;
+ var list = infos.ToList();
+ var itemId = item.Id.ToString("N", CultureInfo.InvariantCulture);
+ var hasDefaultSource = list.Any(i => string.Equals(i.Id, itemId, StringComparison.OrdinalIgnoreCase));
- return info;
+ for (var index = 0; index < list.Count; index++)
+ {
+ var info = list[index];
+ info.RunTimeTicks ??= item.RunTimeTicks;
+
+ if (!string.IsNullOrEmpty(info.Id))
+ {
+ continue;
+ }
+
+ if (!hasDefaultSource)
+ {
+ // The source carrying the item id sorts first and becomes the client's default.
+ info.Id = itemId;
+ hasDefaultSource = true;
+ continue;
+ }
+
+ // Remaining sources need ids that are distinct but stable, as clients send them back to request playback.
+ var key = string.IsNullOrEmpty(info.Path) ? index.ToString(CultureInfo.InvariantCulture) : info.Path;
+ info.Id = (itemId + key).GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ }
+
+ return list;
}
private async Task<Channel> GetChannel(IChannel channelInfo, CancellationToken cancellationToken)
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index 41520f8789..a11f83f2f8 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -125,12 +125,16 @@ public class GuideManager : IGuideManager
{
var innerProgress = new Progress<double>(p => progress.Report(p * progressPerService));
- var idList = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false);
+ var (channelIds, programIds, hasErrors) = await RefreshChannelsInternal(service, innerProgress, cancellationToken).ConfigureAwait(false);
- newChannelIdList.AddRange(idList.Item1);
- newProgramIdList.AddRange(idList.Item2);
+ newChannelIdList.AddRange(channelIds);
+ newProgramIdList.AddRange(programIds);
+
+ // The channels that failed did not report any programs, so cleaning the database
+ // would delete every program they provide instead of keeping the previous ones.
+ cleanDatabase &= !hasErrors;
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
@@ -172,10 +176,12 @@ public class GuideManager : IGuideManager
: 7;
}
- private async Task<Tuple<List<Guid>, List<Guid>>> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken)
+ private async Task<(List<Guid> ChannelIds, List<Guid> ProgramIds, bool HasErrors)> RefreshChannelsInternal(ILiveTvService service, IProgress<double> progress, CancellationToken cancellationToken)
{
progress.Report(10);
+ var hasErrors = false;
+
var allChannelsList = (await service.GetChannelsAsync(cancellationToken).ConfigureAwait(false))
.Select(i => new Tuple<string, ChannelInfo>(service.Name, i))
.ToList();
@@ -195,12 +201,13 @@ public class GuideManager : IGuideManager
list.Add(item);
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
+ hasErrors = true;
_logger.LogError(ex, "Error getting channel information for {Name}", channelInfo.Item2.Name);
}
@@ -314,12 +321,13 @@ public class GuideManager : IGuideManager
},
cancellationToken).ConfigureAwait(false);
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
+ hasErrors = true;
_logger.LogError(ex, "Error getting programs for channel {Name}", currentChannel.Name);
}
@@ -330,7 +338,7 @@ public class GuideManager : IGuideManager
}
progress.Report(100);
- return new Tuple<List<Guid>, List<Guid>>(channels, programIds);
+ return (channels, programIds, hasErrors);
}
private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken)
diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
index 15e20d6f64..7274a3030c 100644
--- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
+++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
@@ -352,9 +352,12 @@ public class ListingsManager : IListingsManager
var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml");
try
{
- File.Delete(xmltvCacheFile);
+ if (File.Exists(xmltvCacheFile))
+ {
+ File.Delete(xmltvCacheFile);
+ }
}
- catch (IOException ex)
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", safeId);
}
diff --git a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
index c93d1f039c..40ec99e9a5 100644
--- a/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
+++ b/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
@@ -549,46 +549,52 @@ namespace Jellyfin.LiveTv.Listings
{
var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
- var lineups = new List<NameIdPair>();
-
if (string.IsNullOrWhiteSpace(token))
{
- return lineups;
+ throw new AuthenticationException("Could not authenticate with Schedules Direct");
}
+ var lineups = new List<NameIdPair>();
+
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location);
options.Headers.TryAddWithoutValidation("token", token);
- try
+ var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false);
+ foreach (HeadendsDto headend in root ?? [])
{
- var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait(false);
- if (root is not null)
+ foreach (LineupDto lineup in headend.Lineups ?? [])
{
- foreach (HeadendsDto headend in root)
+ lineups.Add(new NameIdPair
{
- foreach (LineupDto lineup in headend.Lineups)
- {
- lineups.Add(new NameIdPair
- {
- Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name,
- Id = lineup.Uri?[18..]
- });
- }
- }
- }
- else
- {
- _logger.LogInformation("No lineups available");
+ Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name,
+ Id = string.IsNullOrWhiteSpace(lineup.Lineup) ? lineup.Uri?.Split('/')[^1] : lineup.Lineup
+ });
}
}
- catch (Exception ex)
+
+ if (lineups.Count == 0)
{
- _logger.LogError(ex, "Error getting headends");
+ _logger.LogWarning(
+ "Schedules Direct has no lineups for country {Country} and postal code {PostalCode}",
+ country,
+ location);
}
return lineups;
}
+ private void ResetErrorState(ListingsProviderInfo info)
+ {
+ _accountError = false;
+ Interlocked.Exchange(ref _lastErrorResponseTicks, 0);
+
+ // Only the account being saved is retried, the tokens of the other accounts stay valid.
+ if (!string.IsNullOrWhiteSpace(info.Username))
+ {
+ _tokens.TryRemove(info.Username, out _);
+ }
+ }
+
private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
{
var username = info.Username;
@@ -605,15 +611,19 @@ namespace Jellyfin.LiveTv.Listings
return null;
}
- // Permanent account error — SD is disabled for this server lifetime.
+ // Account error — SD stays disabled until the provider is saved again or the server restarts.
if (_accountError)
{
+ _logger.LogWarning("Skipping Schedules Direct request because of an earlier account error. Save the listings provider again to retry.");
+
return null;
}
// Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout)
if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalMinutes < 30)
{
+ _logger.LogWarning("Skipping Schedules Direct request because of a recent login failure. Retrying no earlier than 30 minutes after it.");
+
return null;
}
@@ -776,7 +786,7 @@ namespace Jellyfin.LiveTv.Listings
return root.Token;
}
- throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message);
+ throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + (root?.Message ?? "empty response"));
}
private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken)
@@ -992,10 +1002,18 @@ namespace Jellyfin.LiveTv.Listings
public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
{
+ ResetErrorState(info);
+
if (validateLogin)
{
ArgumentException.ThrowIfNullOrEmpty(info.Username);
ArgumentException.ThrowIfNullOrEmpty(info.Password);
+
+ var token = await GetToken(info, CancellationToken.None).ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(token))
+ {
+ throw new AuthenticationException("Could not authenticate with Schedules Direct");
+ }
}
if (validateListings)
diff --git a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
index 0aeb7ad05d..f78d464659 100644
--- a/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
+++ b/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@@ -27,11 +28,14 @@ namespace Jellyfin.LiveTv.Listings
public class XmlTvListingsProvider : IListingsProvider
{
private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1);
+ private static readonly TimeSpan _downloadTimeout = TimeSpan.FromMinutes(15);
private readonly IServerConfigurationManager _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<XmlTvListingsProvider> _logger;
+ private readonly ConcurrentDictionary<string, DateTime> _lastDownloadFailures = new(StringComparer.Ordinal);
+
public XmlTvListingsProvider(
IServerConfigurationManager config,
IHttpClientFactory httpClientFactory,
@@ -64,32 +68,51 @@ namespace Jellyfin.LiveTv.Listings
string cacheDir = Path.Join(_config.ApplicationPaths.CachePath, "xmltv");
string cacheFile = Path.Join(cacheDir, cacheFilename);
- if (File.Exists(cacheFile))
+ if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge))
+ {
+ return cacheFile;
+ }
+
+ var isRemote = info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase);
+
+ if (isRemote
+ && _lastDownloadFailures.TryGetValue(info.Path, out var lastFailure)
+ && DateTime.UtcNow - lastFailure < _maxCacheAge)
{
- if (File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge))
+ if (File.Exists(cacheFile))
{
return cacheFile;
}
- File.Delete(cacheFile);
- }
- else
- {
- Directory.CreateDirectory(cacheDir);
+ throw new InvalidOperationException("Skipping the XMLTV download after a recent failure: " + info.Path);
}
+ Directory.CreateDirectory(cacheDir);
+
+ var tempFile = cacheFile + ".tmp";
+
try
{
- if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ using var timeout = new CancellationTokenSource(_downloadTimeout);
+ using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
+ var downloadCancellationToken = linkedTokenSource.Token;
+
+ if (isRemote)
{
_logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
- using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, cancellationToken).ConfigureAwait(false);
+ var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
+ httpClient.Timeout = _downloadTimeout;
+
+ using var response = await httpClient
+ .GetAsync(info.Path, HttpCompletionOption.ResponseHeadersRead, downloadCancellationToken)
+ .ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
- var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ var stream = await response.Content.ReadAsStreamAsync(downloadCancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
{
- return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwait(false);
+ await UnzipIfNeededAndCopy(redirectedUrl, stream, tempFile, downloadCancellationToken).ConfigureAwait(false);
}
}
else
@@ -97,28 +120,63 @@ namespace Jellyfin.LiveTv.Listings
var stream = AsyncFile.OpenRead(info.Path);
await using (stream.ConfigureAwait(false))
{
- return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(false);
+ await UnzipIfNeededAndCopy(info.Path, stream, tempFile, downloadCancellationToken).ConfigureAwait(false);
}
}
+
+ File.Move(tempFile, cacheFile, true);
+ _lastDownloadFailures.TryRemove(info.Path, out _);
+
+ return cacheFile;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ TryDeleteTempFile(tempFile);
+
+ throw;
}
catch (Exception ex)
{
+ TryDeleteTempFile(tempFile);
+ _lastDownloadFailures[info.Path] = DateTime.UtcNow;
+
_logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path);
if (File.Exists(cacheFile))
{
- File.Delete(cacheFile);
+ _logger.LogWarning("Falling back to the previously downloaded XMLTV file for {Path}", info.Path);
+
+ return cacheFile;
+ }
+
+ if (ex is OperationCanceledException)
+ {
+ throw new TimeoutException(
+ string.Format(CultureInfo.InvariantCulture, "Timed out downloading the XMLTV file from {0}", info.Path),
+ ex);
}
throw;
}
}
- private async Task<string> UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken)
+ private void TryDeleteTempFile(string tempFile)
+ {
+ try
+ {
+ File.Delete(tempFile);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ _logger.LogWarning(ex, "Error deleting temporary XMLTV file {File}", tempFile);
+ }
+ }
+
+ private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancellationToken)
{
var fileStream = new FileStream(
file,
- FileMode.CreateNew,
+ FileMode.Create,
FileAccess.Write,
FileShare.None,
IODefaults.FileStreamBufferSize,
@@ -148,15 +206,8 @@ namespace Jellyfin.LiveTv.Listings
var fileInfo = new FileInfo(file);
if (!fileInfo.Exists || fileInfo.Length == 0)
{
- if (fileInfo.Exists)
- {
- File.Delete(file);
- }
-
throw new InvalidOperationException("Downloaded XMLTV file is empty: " + originalUrl);
}
-
- return file;
}
public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTime startDateUtc, DateTime endDateUtc, CancellationToken cancellationToken)
@@ -281,6 +332,13 @@ namespace Jellyfin.LiveTv.Listings
public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
{
+ // Saving the provider is an explicit retry, so the download backoff has to be dropped
+ // together with the cached file the listings manager deletes.
+ if (!string.IsNullOrEmpty(info.Path))
+ {
+ _lastDownloadFailures.TryRemove(info.Path, out _);
+ }
+
// Assume all urls are valid. check files for existence
if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path))
{
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index 2edf7681db..93f9083178 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -1229,6 +1229,14 @@ namespace Jellyfin.LiveTv
.Where(IsLiveTvEnabled);
}
+ /// <inheritdoc />
+ public bool IsEnabledForUser(User user)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+
+ return IsLiveTvEnabled(user);
+ }
+
/// <summary>
/// Resets the tuner.
/// </summary>
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
index 62a06370da..8a5aeccba5 100644
--- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
@@ -286,7 +286,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
if (requiresRefresh)
{
- await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ _libraryManager.QueueLibraryScan();
}
}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
index fb606be0ef..902ca76af8 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
@@ -32,6 +32,7 @@ namespace Jellyfin.LiveTv.TunerHosts
{
private static readonly string[] _mimeTypesCanShareHttpStream = ["video/MP2T"];
private static readonly string[] _extensionsCanShareHttpStream = [".ts", ".tsv", ".m2t"];
+ private static readonly string[] _manifestExtensions = [".m3u8", ".m3u", ".mpd"];
private readonly IHttpClientFactory _httpClientFactory;
private readonly IServerApplicationHost _appHost;
@@ -151,11 +152,20 @@ namespace Jellyfin.LiveTv.TunerHosts
var protocol = _mediaSourceManager.GetPathProtocol(path);
var isRemote = true;
- if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
+ Uri.TryCreate(path, UriKind.Absolute, out var uri);
+ if (uri is not null)
{
isRemote = !_networkManager.IsInLocalNetwork(uri.Host);
}
+ // A manifest is not a byte stream. Serving one directly hands the client a playlist whose
+ // variant and segment URIs are relative to the origin, and those do not resolve against the
+ // Jellyfin url the client fetched it from. Remux or transcode these instead.
+ if (IsManifest(path, uri))
+ {
+ supportsDirectPlay = false;
+ }
+
var httpHeaders = new Dictionary<string, string>();
if (protocol == MediaProtocol.Http)
@@ -210,6 +220,20 @@ namespace Jellyfin.LiveTv.TunerHosts
return mediaSource;
}
+ /// <summary>
+ /// Determines whether a channel path points at an HLS or DASH manifest rather than at a byte stream.
+ /// </summary>
+ /// <param name="path">The channel path.</param>
+ /// <param name="uri">The channel path parsed as an absolute uri, or <c>null</c> if it is not one.</param>
+ /// <returns><c>true</c> if the path names a streaming manifest.</returns>
+ private static bool IsManifest(string path, Uri uri)
+ {
+ // Use the uri path when there is one so that a query string does not hide the extension.
+ var extension = Path.GetExtension(uri is null ? path : uri.AbsolutePath);
+
+ return _manifestExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
+ }
+
public Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
{
return Task.FromResult(new List<TunerHostInfo>());
diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
index 1f59908a86..e57fbfe473 100644
--- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
+++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+using System.IO;
using System.Linq;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
@@ -8,29 +10,31 @@ namespace Jellyfin.Controller.Tests
{
public class DirectoryServiceTests
{
- private const string LowerCasePath = "/music/someartist";
- private const string UpperCasePath = "/music/SOMEARTIST";
+ // Path.GetDirectoryName, which Invalidate uses to find the parent, normalizes the
+ // separators, so cache keys only match the parent it returns when they use the platform's.
+ private static readonly string _lowerCasePath = LocalPath("/music/someartist");
+ private static readonly string _upperCasePath = LocalPath("/music/SOMEARTIST");
private static readonly FileSystemMetadata[] _lowerCaseFileSystemMetadata =
{
new()
{
- FullName = LowerCasePath + "/Artwork",
+ FullName = Path.Combine(_lowerCasePath, "Artwork"),
IsDirectory = true
},
new()
{
- FullName = LowerCasePath + "/Some Other Folder",
+ FullName = Path.Combine(_lowerCasePath, "Some Other Folder"),
IsDirectory = true
},
new()
{
- FullName = LowerCasePath + "/Song 2.mp3",
+ FullName = Path.Combine(_lowerCasePath, "Song 2.mp3"),
IsDirectory = false
},
new()
{
- FullName = LowerCasePath + "/Song 3.mp3",
+ FullName = Path.Combine(_lowerCasePath, "Song 3.mp3"),
IsDirectory = false
}
};
@@ -39,12 +43,12 @@ namespace Jellyfin.Controller.Tests
{
new()
{
- FullName = UpperCasePath + "/Lyrics",
+ FullName = Path.Combine(_upperCasePath, "Lyrics"),
IsDirectory = true
},
new()
{
- FullName = UpperCasePath + "/Song 1.mp3",
+ FullName = Path.Combine(_upperCasePath, "Song 1.mp3"),
IsDirectory = false
}
};
@@ -53,12 +57,12 @@ namespace Jellyfin.Controller.Tests
public void GetFileSystemEntries_GivenPathsWithDifferentCasing_CachesAll()
{
var fileSystemMock = new Mock<IFileSystem>();
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
- var upperCaseResult = directoryService.GetFileSystemEntries(UpperCasePath);
- var lowerCaseResult = directoryService.GetFileSystemEntries(LowerCasePath);
+ var upperCaseResult = directoryService.GetFileSystemEntries(_upperCasePath);
+ var lowerCaseResult = directoryService.GetFileSystemEntries(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata, upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata, lowerCaseResult);
@@ -68,12 +72,12 @@ namespace Jellyfin.Controller.Tests
public void GetFiles_GivenPathsWithDifferentCasing_ReturnsCorrectFiles()
{
var fileSystemMock = new Mock<IFileSystem>();
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
- var upperCaseResult = directoryService.GetFiles(UpperCasePath);
- var lowerCaseResult = directoryService.GetFiles(LowerCasePath);
+ var upperCaseResult = directoryService.GetFiles(_upperCasePath);
+ var lowerCaseResult = directoryService.GetFiles(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata.Where(f => !f.IsDirectory), upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => !f.IsDirectory), lowerCaseResult);
@@ -83,12 +87,12 @@ namespace Jellyfin.Controller.Tests
public void GetDirectories_GivenPathsWithDifferentCasing_ReturnsCorrectDirectories()
{
var fileSystemMock = new Mock<IFileSystem>();
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
- fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
- var upperCaseResult = directoryService.GetDirectories(UpperCasePath);
- var lowerCaseResult = directoryService.GetDirectories(LowerCasePath);
+ var upperCaseResult = directoryService.GetDirectories(_upperCasePath);
+ var lowerCaseResult = directoryService.GetDirectories(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata.Where(f => f.IsDirectory), upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => f.IsDirectory), lowerCaseResult);
@@ -248,5 +252,171 @@ namespace Jellyfin.Controller.Tests
Assert.Equal(cachedPaths, result);
Assert.Equal(newPaths, secondResult);
}
+
+ [Fact]
+ public void GetFileSystemEntries_RepeatedPath_ReadsTheFileSystemOnce()
+ {
+ var fileSystemMock = new Mock<IFileSystem>(MockBehavior.Strict);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(_lowerCasePath))
+ .Returns(_lowerCaseFileSystemMetadata);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ directoryService.GetFileSystemEntries(_lowerCasePath);
+ directoryService.GetFileSystemEntries(_lowerCasePath);
+
+ fileSystemMock.Verify(f => f.GetFileSystemEntries(_lowerCasePath), Times.Once);
+ }
+
+ [Fact]
+ public void Invalidate_GivenADirectory_DropsBothTheListingAndTheFilePaths()
+ {
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath))
+ .Returns(_lowerCaseFileSystemMetadata)
+ .Returns(_upperCaseFileSystemMetadata);
+ fileSystemMock.SetupSequence(f => f.GetFilePaths(_lowerCasePath, false))
+ .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") })
+ .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3"), Path.Combine(_lowerCasePath, "Song 2.srt") });
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+ directoryService.GetFileSystemEntries(_lowerCasePath);
+ directoryService.GetFilePaths(_lowerCasePath);
+
+ directoryService.Invalidate(_lowerCasePath);
+
+ Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath));
+ Assert.Equal(2, directoryService.GetFilePaths(_lowerCasePath).Count);
+ }
+
+ [Fact]
+ public void Invalidate_GivenAFile_DropsTheListingOfTheDirectoryHoldingIt()
+ {
+ var newFile = Path.Combine(_lowerCasePath, "Song 2.srt");
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath))
+ .Returns(_lowerCaseFileSystemMetadata)
+ .Returns(_upperCaseFileSystemMetadata);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+ directoryService.GetFileSystemEntries(_lowerCasePath);
+
+ directoryService.Invalidate(newFile);
+
+ Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath));
+ }
+
+ [Fact]
+ public void GetFilePaths_ClearingTheCache_KeepsTheParentDirectory()
+ {
+ var parentPath = LocalPath("/music");
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.Setup(f => f.GetFilePaths(_lowerCasePath))
+ .Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") });
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(parentPath))
+ .Returns(_lowerCaseFileSystemMetadata);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+ directoryService.GetFileSystemEntries(parentPath);
+
+ directoryService.GetFilePaths(_lowerCasePath, true);
+
+ directoryService.GetFileSystemEntries(parentPath);
+ fileSystemMock.Verify(f => f.GetFileSystemEntries(parentPath), Times.Once);
+ }
+
+ [Fact]
+ public void GetFileSystemEntries_MoreRecordsThanTheCeiling_DropsCache()
+ {
+ // Charged by the files in a listing, not the number of listings, so a few big folders
+ // reach the limit where a lot of small ones would not.
+ const int FolderCount = 60;
+ var bigListing = new FileSystemMetadata[5000];
+ for (var i = 0; i < bigListing.Length; i++)
+ {
+ bigListing[i] = new FileSystemMetadata
+ {
+ FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture),
+ IsDirectory = false
+ };
+ }
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>()))
+ .Returns(bigListing);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ const string FirstPath = "/music/artist0";
+ directoryService.GetFileSystemEntries(FirstPath);
+
+ for (var i = 1; i < FolderCount; i++)
+ {
+ directoryService.GetFileSystemEntries("/music/artist" + i.ToString(CultureInfo.InvariantCulture));
+ }
+
+ directoryService.GetFileSystemEntries(FirstPath);
+
+ fileSystemMock.Verify(f => f.GetFileSystemEntries(FirstPath), Times.Exactly(2));
+ }
+
+ [Fact]
+ public void GetFileSystemEntries_RepeatedlyInvalidatedFolder_KeepsUnrelatedEntriesCached()
+ {
+ // Invalidating gives the records back, so churning one folder must not add up to the
+ // ceiling and drop everything else with it.
+ const int ChurnCount = 50;
+ var bigListing = new FileSystemMetadata[5000];
+ for (var i = 0; i < bigListing.Length; i++)
+ {
+ bigListing[i] = new FileSystemMetadata
+ {
+ FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture),
+ IsDirectory = false
+ };
+ }
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>()))
+ .Returns(bigListing);
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ const string ChurnedPath = "/music/watched";
+ const string StablePath = "/music/untouched";
+ directoryService.GetFileSystemEntries(StablePath);
+
+ for (var i = 0; i < ChurnCount; i++)
+ {
+ directoryService.GetFileSystemEntries(ChurnedPath);
+ directoryService.Invalidate(ChurnedPath);
+ }
+
+ directoryService.GetFileSystemEntries(StablePath);
+
+ fileSystemMock.Verify(f => f.GetFileSystemEntries(StablePath), Times.Once);
+ }
+
+ [Fact]
+ public void GetFileSystemEntry_MissingPath_IsNotRemembered()
+ {
+ const string MissingPath = "/music/not-here";
+
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.SetupSequence(f => f.GetFileSystemInfo(MissingPath))
+ .Returns(new FileSystemMetadata { FullName = MissingPath, Exists = false })
+ .Returns(new FileSystemMetadata { FullName = MissingPath, Exists = true });
+
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ Assert.Null(directoryService.GetFileSystemEntry(MissingPath));
+
+ Assert.NotNull(directoryService.GetFileSystemEntry(MissingPath));
+ }
+
+ private static string LocalPath(string path)
+ => path.Replace('/', Path.DirectorySeparatorChar);
}
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs
new file mode 100644
index 0000000000..272c434fe9
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/AggregateFolderTests.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+[Collection("LibraryManagerTests")]
+public class AggregateFolderTests
+{
+ [Fact]
+ public void Children_ClearedAfterALibraryWasAdded_ListsTheNewLibrary()
+ {
+ var existing = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" };
+ var added = new Folder { Id = Guid.NewGuid(), Path = "/libraries/collections" };
+
+ // What the repository holds grows once the new library has been resolved and stored.
+ var stored = new List<BaseItem> { existing };
+
+ var itemRepository = new Mock<IItemRepository>();
+ itemRepository.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(() => stored.ToList());
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetItemById(It.IsAny<Guid>()))
+ .Returns((Guid id) => stored.Find(i => i.Id.Equals(id)));
+
+ BaseItem.ItemRepository = itemRepository.Object;
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" };
+
+ Assert.Equal([existing.Id], root.Children.Select(i => i.Id));
+
+ stored.Add(added);
+ root.Children = null;
+
+ // Null-forgiving: the setter takes null to mean "drop the cache", the getter reloads.
+ Assert.Equal([existing.Id, added.Id], root.Children!.Select(i => i.Id));
+ }
+
+ [Fact]
+ public void Children_AssignedASet_KeepsThatSet()
+ {
+ var itemRepository = new Mock<IItemRepository>(MockBehavior.Strict);
+ BaseItem.ItemRepository = itemRepository.Object;
+
+ var assigned = new Folder { Id = Guid.NewGuid(), Path = "/libraries/movies" };
+ var root = new AggregateFolder { Id = Guid.NewGuid(), Path = "/libraries" };
+
+ root.Children = [assigned];
+
+ // Never goes to the repository, so the strict mock stays unused.
+ Assert.Equal([assigned.Id], root.Children.Select(i => i.Id));
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 86bac4256a..e072bccb82 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -26,8 +27,63 @@ using Xunit;
namespace Jellyfin.Controller.Tests.Entities;
+[Collection("LibraryManagerTests")]
public class BaseItemTests
{
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ [InlineData(true, true)]
+ public async Task ValidateChildren_FailedEnumeration_DoesNotReconcileOrDeleteChildren(bool failAfterFirstChild, bool accessDenied)
+ {
+ var previousLibrary = BaseItem.LibraryManager;
+ var previousRepository = BaseItem.ItemRepository;
+ var previousLogger = BaseItem.Logger;
+ var library = new Mock<ILibraryManager>(MockBehavior.Strict);
+ var repository = new Mock<MediaBrowser.Controller.Persistence.IItemRepository>(MockBehavior.Strict);
+ var directory = new Mock<IDirectoryService>();
+ directory.Setup(d => d.IsAccessible(It.IsAny<string>())).Returns(true);
+ try
+ {
+ BaseItem.LibraryManager = library.Object;
+ BaseItem.ItemRepository = repository.Object;
+ BaseItem.Logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<BaseItem>.Instance;
+ var folder = new FailingEnumerationFolder(failAfterFirstChild, accessDenied)
+ {
+ Id = Guid.NewGuid(),
+ Path = "/media/review-folder"
+ };
+ await folder.ValidateChildren(new Progress<double>(), new MetadataRefreshOptions(directory.Object), recursive: false, cancellationToken: TestContext.Current.CancellationToken).ConfigureAwait(true);
+ Assert.True(folder.EnumerationAttempted);
+ repository.VerifyNoOtherCalls();
+ library.VerifyNoOtherCalls();
+ }
+ finally
+ {
+ BaseItem.LibraryManager = previousLibrary;
+ BaseItem.ItemRepository = previousRepository;
+ BaseItem.Logger = previousLogger;
+ }
+ }
+
+ [Fact]
+ public void SetPrimaryVersionId_Null_RestoresTheItemsOwnPresentationKey()
+ {
+ var primaryId = Guid.NewGuid();
+ var video = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv" };
+
+ // While it is a version, it presents as the primary so lists collapse the two together.
+ video.SetPrimaryVersionId(primaryId);
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey);
+
+ // Promoting it back has to restore its own key, or it keeps collapsing onto - and staying
+ // hidden behind - a primary it no longer belongs to.
+ video.SetPrimaryVersionId(null);
+ Assert.Null(video.PrimaryVersionId);
+ Assert.Equal(video.Id.ToString("N", CultureInfo.InvariantCulture), video.PresentationUniqueKey);
+ }
+
[Fact]
public void GetItemByNameFolderName_ShortName_IsKeptAsIs()
{
@@ -185,6 +241,17 @@ public class BaseItemTests
"Blade Runner (1982) [EE by ADM] [480p HEVC AAC]",
"[Final Cut] [1080p HEVC AAC]",
"[EE by ADM] [480p HEVC AAC]")]
+ // Numeric version labels: the dot between the digits is a decimal point, not a delimiter, so the
+ // prefix retreats past it to the '-' instead of leaving "0" / "11".
+ [InlineData(
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0",
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11",
+ "1.0",
+ "1.11")]
+ // Numeric labels with no structural delimiter at all fall back to the space boundary.
+ [InlineData("Movie (2007) 1.0", "Movie (2007) 1.11", "1.0", "1.11")]
+ // A dot followed by a non-digit is still a delimiter, even after a digit.
+ [InlineData("Movie - Part 1.HDR", "Movie - Part 1.SDR", "HDR", "SDR")]
public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt)
{
var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv";
@@ -216,6 +283,24 @@ public class BaseItemTests
}
[Fact]
+ public void GetCommonVersionPrefix_NumericLabels_KeepsWholeNumber()
+ {
+ // Three versions labelled "1.0", "1.01" and "1.11": the common prefix stops inside the version
+ // number, so it must retreat past the decimal point to the '-' delimiter.
+ string[] fileNames =
+ [
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.0",
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.01",
+ "Evangelion 1.0 You Are (Not) Alone (2007) - 1.11"
+ ];
+
+ var prefix = BaseItem.GetCommonVersionPrefix(fileNames);
+
+ Assert.Equal("Evangelion 1.0 You Are (Not) Alone (2007) -", prefix);
+ Assert.Equal(["1.0", "1.01", "1.11"], fileNames.Select(n => n[prefix.Length..].TrimStart(' ')));
+ }
+
+ [Fact]
public void GetAlternateVersion_ReturnsMatchingLocalVersion()
{
var (primary, alt1, alt2) = SetupVersionGroup();
@@ -644,4 +729,25 @@ public class BaseItemTests
Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids);
}
+
+ private sealed class FailingEnumerationFolder(bool failAfterFirstChild, bool accessDenied) : Folder
+ {
+ public bool EnumerationAttempted { get; private set; }
+
+ protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService)
+ {
+ EnumerationAttempted = true;
+ if (failAfterFirstChild)
+ {
+ yield return new Movie { Id = Guid.NewGuid(), Path = "/media/review-folder/movie.mkv" };
+ }
+
+ if (accessDenied)
+ {
+ throw new System.Security.SecurityException("Simulated access failure");
+ }
+
+ throw new IOException("Simulated directory read failure");
+ }
+ }
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs
new file mode 100644
index 0000000000..705238317a
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/FolderChildCacheTests.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+/// <summary>
+/// Covers <see cref="Folder.ReleaseCachedChildren"/>, which a recursive scan calls as it unwinds so
+/// the folders it walked do not keep the whole item graph of the library alive behind it.
+/// </summary>
+public class FolderChildCacheTests
+{
+ [Fact]
+ public void ReleaseCachedChildren_MakesTheNextAccessReload()
+ {
+ var folder = new TrackingFolder();
+
+ Assert.Empty(folder.Children);
+ Assert.Equal(1, folder.LoadCount);
+
+ // Second access is served from the cache on the instance.
+ Assert.Empty(folder.Children);
+ Assert.Equal(1, folder.LoadCount);
+
+ folder.ReleaseCachedChildren();
+
+ Assert.Empty(folder.Children);
+ Assert.Equal(2, folder.LoadCount);
+ }
+
+ [Fact]
+ public void ReleaseCachedChildren_ReachesEveryLevelBelow()
+ {
+ var leaf = new TrackingFolder();
+ var middle = new TrackingFolder { Source = [leaf] };
+ var root = new TrackingFolder { Source = [middle] };
+
+ // Walk the whole tree, as a recursive scan does, so every level holds its children.
+ Assert.Single(root.Children);
+ Assert.Single(middle.Children);
+ Assert.Empty(leaf.Children);
+ Assert.Equal(1, root.LoadCount);
+ Assert.Equal(1, middle.LoadCount);
+ Assert.Equal(1, leaf.LoadCount);
+
+ root.ReleaseCachedChildren();
+
+ Assert.Single(root.Children);
+ Assert.Single(middle.Children);
+ Assert.Empty(leaf.Children);
+ Assert.Equal(2, root.LoadCount);
+ Assert.Equal(2, middle.LoadCount);
+ Assert.Equal(2, leaf.LoadCount);
+ }
+
+ [Fact]
+ public void ReleaseCachedChildren_LoadsNothingThatIsNotAlreadyHeld()
+ {
+ var leaf = new TrackingFolder();
+ var root = new TrackingFolder { Source = [leaf] };
+
+ root.ReleaseCachedChildren();
+
+ Assert.Equal(0, root.LoadCount);
+ Assert.Equal(0, leaf.LoadCount);
+ }
+
+ [Fact]
+ public void ReleaseCachedChildren_TerminatesOnACycle()
+ {
+ var first = new TrackingFolder();
+ var second = new TrackingFolder { Source = [first] };
+ first.Source = [second];
+
+ Assert.Single(first.Children);
+ Assert.Single(second.Children);
+
+ // Clearing before descending is what stops this from recursing forever.
+ first.ReleaseCachedChildren();
+
+ Assert.Equal(1, first.LoadCount);
+ Assert.Equal(1, second.LoadCount);
+ }
+
+ private sealed class TrackingFolder : Folder
+ {
+ public int LoadCount { get; private set; }
+
+ public IReadOnlyList<BaseItem> Source { get; set; } = [];
+
+ protected override IReadOnlyList<BaseItem> LoadChildren()
+ {
+ LoadCount++;
+ return Source;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs
new file mode 100644
index 0000000000..70da5eafe5
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/Entities/PlaylistTests.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Linq;
+using Jellyfin.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Playlists;
+using MediaBrowser.Model.Querying;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.Entities;
+
+public class PlaylistTests
+{
+ [Fact]
+ public void IsVisible_PlaylistWithNothingLeftInIt_IsHidden()
+ {
+ // The SQL parental filter hides a container whose every member is blocked, so a listing
+ // built in memory has to reach the same answer.
+ var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" };
+ SetupLibrary(blocked);
+
+ Assert.False(BuildPlaylist(blocked).IsVisible(BuildRestrictedUser()));
+ }
+
+ [Fact]
+ public void IsVisible_PlaylistWithOneAllowedItem_StaysVisible()
+ {
+ var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" };
+ var allowed = new Audio { Id = Guid.NewGuid(), Name = "Song" };
+ SetupLibrary(blocked, allowed);
+
+ Assert.True(BuildPlaylist(blocked, allowed).IsVisible(BuildRestrictedUser()));
+ }
+
+ [Fact]
+ public void IsVisible_UnrestrictedUser_LeavesTheItemsUnresolved()
+ {
+ var blocked = new Movie { Id = Guid.NewGuid(), Name = "Movie" };
+ var libraryManager = SetupLibrary(blocked);
+ var user = new User("user", "auth-provider", "reset-provider");
+
+ Assert.True(BuildPlaylist(blocked).IsVisible(user));
+
+ // Resolving a playlist's items is a query per playlist; nothing may run it for a user no
+ // rating keeps anything from.
+ libraryManager.Verify(x => x.GetItemList(It.IsAny<InternalItemsQuery>()), Times.Never);
+ }
+
+ private static Mock<ILibraryManager> SetupLibrary(params BaseItem[] items)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager
+ .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(items);
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ return libraryManager;
+ }
+
+ private static Playlist BuildPlaylist(params BaseItem[] items)
+ {
+ // An empty path keeps the playlist out of the shared-playlist branch.
+ return new Playlist
+ {
+ Id = Guid.NewGuid(),
+ Name = "Playlist",
+ LinkedChildren = items.Select(LinkedChild.Create).ToArray()
+ };
+ }
+
+ private static User BuildRestrictedUser()
+ {
+ var user = new User("user", "auth-provider", "reset-provider") { MaxParentalRatingScore = 5 };
+ user.SetPreference(PreferenceKind.BlockUnratedItems, new[] { UnratedItem.Movie });
+
+ return user;
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs
new file mode 100644
index 0000000000..686d839f4f
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.LibraryTaskScheduler;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.LibraryTaskScheduler
+{
+ public class LimitedConcurrencyLibrarySchedulerTests
+ {
+ private static readonly TimeSpan _shortGracePeriod = TimeSpan.FromMilliseconds(50);
+
+ // Generous, because these only ever wait for something that should already have happened.
+ private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10);
+
+ [Fact]
+ public async Task Enqueue_ProcessesEveryItem()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping);
+ await using (scheduler)
+ {
+ var data = Enumerable.Range(0, 100).ToArray();
+ var processed = new ConcurrentBag<int>();
+
+ await scheduler.Enqueue(
+ data,
+ (item, _) =>
+ {
+ processed.Add(item);
+ return Task.CompletedTask;
+ },
+ new Progress<double>(),
+ CancellationToken.None);
+
+ Assert.Equal(data, processed.Order());
+ }
+ }
+
+ [Fact]
+ public async Task Enqueue_WithFailingWorker_StillCompletes()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping);
+ await using (scheduler)
+ {
+ await scheduler.Enqueue(
+ Enumerable.Range(0, 20).ToArray(),
+ (item, _) => item % 2 == 0 ? throw new InvalidOperationException("boom") : Task.CompletedTask,
+ new Progress<double>(),
+ CancellationToken.None);
+ }
+ }
+
+ /// <summary>
+ /// The runners wait on a source linked to <see cref="IHostApplicationLifetime.ApplicationStopping"/>,
+ /// so a shutdown has to reach them. It does not travel from the linked source back to the one
+ /// the cleanup cancels, which is what made them immortal.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
+ [Fact]
+ public async Task ApplicationStopping_RetiresRunners()
+ {
+ using var appStopping = new CancellationTokenSource();
+
+ // Long enough that the cleanup cannot be what retires them.
+ var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
+ await using (scheduler)
+ {
+ await RunOneOperation(scheduler);
+ Assert.True(scheduler.ActiveRunnerCount > 0);
+
+ await appStopping.CancelAsync();
+
+ await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
+ }
+ }
+
+ /// <summary>
+ /// The cleanup used to be a one shot: it never released the scheduling slot it took, so
+ /// every runner spawned after the first pass stayed around for the lifetime of the server.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
+ [Fact]
+ public async Task Enqueue_RetiresIdleRunnersAfterEveryOperation()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping);
+ await using (scheduler)
+ {
+ for (var round = 0; round < 3; round++)
+ {
+ await RunOneOperation(scheduler);
+ Assert.True(scheduler.ActiveRunnerCount > 0, $"no runner spawned in round {round}");
+
+ await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Disposing used to sit out the rest of the cleanup grace period, holding up shutdown for
+ /// up to a minute.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
+ [Fact]
+ public async Task DisposeAsync_DoesNotWaitOutTheGracePeriod()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
+
+ await RunOneOperation(scheduler);
+
+ var stopwatch = Stopwatch.StartNew();
+ await scheduler.DisposeAsync();
+
+ Assert.True(stopwatch.Elapsed < _timeout, $"disposing took {stopwatch.Elapsed}");
+ }
+
+ [Fact]
+ public async Task Enqueue_AfterDispose_DoesNothing()
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping);
+ await scheduler.DisposeAsync();
+
+ var processed = 0;
+ await scheduler.Enqueue(
+ Enumerable.Range(0, 10).ToArray(),
+ (_, _) =>
+ {
+ Interlocked.Increment(ref processed);
+ return Task.CompletedTask;
+ },
+ new Progress<double>(),
+ CancellationToken.None);
+
+ Assert.Equal(0, processed);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(4)]
+ public async Task Enqueue_FromWithinAWorker_DoesNotDeadlock(int fanout)
+ {
+ using var appStopping = new CancellationTokenSource();
+ var scheduler = CreateScheduler(appStopping, fanout: fanout);
+ await using (scheduler)
+ {
+ var inner = 0;
+
+ var outer = scheduler.Enqueue(
+ Enumerable.Range(0, 8).ToArray(),
+ (_, _) => scheduler.Enqueue(
+ Enumerable.Range(0, 4).ToArray(),
+ (_, _) =>
+ {
+ Interlocked.Increment(ref inner);
+ return Task.CompletedTask;
+ },
+ new Progress<double>(),
+ CancellationToken.None),
+ new Progress<double>(),
+ CancellationToken.None);
+
+ await outer.WaitAsync(_timeout, TestContext.Current.CancellationToken);
+
+ Assert.Equal(32, inner);
+ }
+ }
+
+ private static LimitedConcurrencyLibraryScheduler CreateScheduler(
+ CancellationTokenSource appStopping,
+ int fanout = 4,
+ TimeSpan? gracePeriod = null)
+ {
+ var lifetime = new Mock<IHostApplicationLifetime>();
+ lifetime.SetupGet(x => x.ApplicationStopping).Returns(() => appStopping.Token);
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager.SetupGet(x => x.Configuration)
+ .Returns(new ServerConfiguration { LibraryScanFanoutConcurrency = fanout });
+
+ return new LimitedConcurrencyLibraryScheduler(
+ lifetime.Object,
+ NullLogger<LimitedConcurrencyLibraryScheduler>.Instance,
+ configurationManager.Object,
+ gracePeriod ?? _shortGracePeriod);
+ }
+
+ private static Task RunOneOperation(LimitedConcurrencyLibraryScheduler scheduler)
+ => scheduler.Enqueue(
+ Enumerable.Range(0, 8).ToArray(),
+ (_, _) => Task.CompletedTask,
+ new Progress<double>(),
+ CancellationToken.None);
+
+ private static async Task WaitForAsync(Func<bool> condition)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ while (!condition())
+ {
+ Assert.True(stopwatch.Elapsed < _timeout, "timed out waiting for the scheduler to settle");
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs
new file mode 100644
index 0000000000..557035e2d1
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs
@@ -0,0 +1,162 @@
+using System;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Streaming;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using Moq;
+using Xunit;
+
+using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding;
+
+public class EncodingHelperDoviTests
+{
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData("bt709", false)]
+ [InlineData("unknown", false)]
+ [InlineData("bt2020-10", false)]
+ [InlineData("smpte2084", true)]
+ [InlineData("arib-std-b67", true)]
+ public void GetSwVidFilterChain_InvalidDovi_OnlyTonemapsHdrBaseLayer(string? transfer, bool tonemap)
+ {
+ var state = CreateState("hevc", transfer);
+ var helper = CreateHelper(true);
+
+ var (filters, _, _) = helper.GetSwVidFilterChain(state, new EncodingOptions(), "libx264");
+ var args = string.Join(',', filters);
+
+ Assert.Equal(VideoRangeType.DOVIInvalid, state.VideoStream.VideoRangeType);
+ Assert.Equal(tonemap, args.Contains("tonemapx=", StringComparison.Ordinal));
+ Assert.Contains(tonemap ? "color_trc=" + transfer : "color_trc=bt709", args, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData("bt709", false)]
+ [InlineData("arib-std-b67", false)]
+ [InlineData("smpte2084", true)]
+ [InlineData("SMPTE2084", true)]
+ public void IsDoviWithHdr10Bl_InvalidDovi_RequiresPq(string? transfer, bool expected)
+ {
+ var stream = CreateState("hevc", transfer).VideoStream;
+
+ Assert.True(EncodingHelper.IsDovi(stream));
+ Assert.Equal(expected, EncodingHelper.IsDoviWithHdr10Bl(stream));
+ }
+
+ [Theory]
+ [InlineData("hevc", null, "hevc_metadata=remove_dovi=1")]
+ [InlineData("hevc", "bt709", "hevc_metadata=remove_dovi=1")]
+ [InlineData("hevc", "smpte2084", "hevc_metadata=remove_dovi=1")]
+ [InlineData("hevc", "arib-std-b67", "hevc_metadata=remove_dovi=1")]
+ [InlineData("av1", null, "av1_metadata=remove_dovi=1")]
+ [InlineData("av1", "bt709", "av1_metadata=remove_dovi=1")]
+ [InlineData("av1", "smpte2084", "av1_metadata=remove_dovi=1")]
+ [InlineData("av1", "arib-std-b67", "av1_metadata=remove_dovi=1")]
+ public void GetBitStreamArgs_InvalidDovi_PreservesClientDependentRemoval(string codec, string? transfer, string expected)
+ {
+ var state = CreateState(codec, transfer);
+ var helper = CreateHelper(true);
+
+ foreach (var (requestedRanges, removeDovi) in new[] { (null, false), ("SDR", false), ("HDR10", false), ("DOVIWithEL", false), ("DOVI", true), ("SDR,DOVI", true) })
+ {
+ state.BaseRequest.VideoRangeType = requestedRanges;
+
+ Assert.Equal(removeDovi, helper.IsDoviRemoved(state));
+ if (removeDovi)
+ {
+ Assert.Contains(expected, helper.GetBitStreamArgs(state, MediaStreamType.Video), StringComparison.Ordinal);
+ }
+ else
+ {
+ Assert.Equal(codec == "hevc" ? "-bsf:v hevc_mp4toannexb" : null, helper.GetBitStreamArgs(state, MediaStreamType.Video));
+ }
+
+ Assert.False(CreateHelper(false).IsDoviRemoved(state));
+ }
+ }
+
+ [Theory]
+ [InlineData(null, true)]
+ [InlineData("HDR10", true)]
+ [InlineData("DOVI", false)]
+ [InlineData("SDR,DOVI", false)]
+ public void CanStreamCopyVideo_InvalidDovi_RequiresRemovalSupportOnlyForDoviClients(string? requestedRanges, bool copyWithoutRemovalSupport)
+ {
+ foreach (var codec in new[] { "hevc", "av1" })
+ {
+ foreach (var transfer in new[] { "bt709", "smpte2084" })
+ {
+ var state = CreateState(codec, transfer);
+ state.BaseRequest.VideoRangeType = requestedRanges;
+
+ Assert.True(CreateHelper(true).CanStreamCopyVideo(state, state.VideoStream));
+ Assert.Equal(copyWithoutRemovalSupport, CreateHelper(false).CanStreamCopyVideo(state, state.VideoStream));
+ }
+ }
+ }
+
+ [Fact]
+ public void GetBitStreamArgs_ValidDovi_PreservesMetadata()
+ {
+ var state = CreateState("hevc", "smpte2084");
+ state.VideoStream.ColorSpace = "bt2020nc";
+ state.VideoStream.ColorPrimaries = "bt2020";
+ state.BaseRequest.VideoRangeType = "DOVIWithEL";
+ var helper = CreateHelper(true);
+
+ Assert.False(helper.IsDoviRemoved(state));
+ Assert.Equal("-bsf:v hevc_mp4toannexb", helper.GetBitStreamArgs(state, MediaStreamType.Video));
+ }
+
+ private static EncodingJobInfo CreateState(string codec, string? transfer)
+ {
+ var stream = new MediaStream
+ {
+ Type = MediaStreamType.Video,
+ Codec = codec,
+ Width = 1920,
+ Height = 1080,
+ BitDepth = 10,
+ DvProfile = codec == "hevc" ? 7 : 10,
+ DvBlSignalCompatibilityId = codec == "hevc" ? 6 : 1,
+ RpuPresentFlag = 1,
+ BlPresentFlag = 1,
+ ColorSpace = "bt709",
+ ColorPrimaries = "bt709",
+ ColorTransfer = transfer
+ };
+
+ return new EncodingJobInfo(TranscodingJobType.Hls)
+ {
+ VideoStream = stream,
+ MediaSource = new MediaSourceInfo { Container = "mkv", MediaStreams = [stream] },
+ BaseRequest = new VideoRequestDto(),
+ OutputVideoCodec = "copy",
+ IsVideoRequest = true,
+ IsInputVideo = true
+ };
+ }
+
+ private static EncodingHelper CreateHelper(bool supportsRemoval)
+ {
+ var encoder = new Mock<IMediaEncoder>();
+ encoder.Setup(x => x.SupportsBitStreamFilterWithOption(It.IsAny<BitStreamFilterOptionType>())).Returns(supportsRemoval);
+ encoder.Setup(x => x.SupportsFilter("tonemapx")).Returns(true);
+ encoder.SetupGet(x => x.EncoderVersion).Returns(new Version(8, 1));
+
+ return new EncodingHelper(
+ Mock.Of<IApplicationPaths>(),
+ encoder.Object,
+ Mock.Of<ISubtitleEncoder>(),
+ Mock.Of<IConfiguration>(),
+ Mock.Of<IConfigurationManager>(),
+ Mock.Of<IPathManager>());
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs
new file mode 100644
index 0000000000..586db2dd50
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs
@@ -0,0 +1,40 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using Moq;
+using Xunit;
+using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding;
+
+public class EncodingHelperInferAudioCodecTests
+{
+ [Theory]
+ // Manifests and other containers that carry no inferable audio codec.
+ [InlineData("m3u8", "aac")]
+ [InlineData("mpd", "aac")]
+ [InlineData("wtv", "aac")]
+ [InlineData("", "aac")]
+ // Containers with a well known audio codec.
+ [InlineData("mp4", "aac")]
+ [InlineData("mkv", "aac")]
+ [InlineData("webm", "opus")]
+ [InlineData("ts", "mp3")]
+ // Containers named after the codec they carry.
+ [InlineData("flac", "flac")]
+ [InlineData("opus", "opus")]
+ [InlineData("ac3", "ac3")]
+ public void InferAudioCodec_ReturnsAnAudioCodec(string container, string expected)
+ {
+ Assert.Equal(expected, Create().InferAudioCodec(container));
+ }
+
+ private static EncodingHelper Create()
+ => new(
+ Mock.Of<IApplicationPaths>(),
+ Mock.Of<IMediaEncoder>(),
+ Mock.Of<ISubtitleEncoder>(),
+ Mock.Of<IConfiguration>(),
+ Mock.Of<IConfigurationManager>(),
+ Mock.Of<IPathManager>());
+}
diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs
new file mode 100644
index 0000000000..18518df1c2
--- /dev/null
+++ b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderResizeTests.cs
@@ -0,0 +1,100 @@
+using SkiaSharp;
+using Xunit;
+
+namespace Jellyfin.Drawing.Skia.Tests;
+
+/// <summary>
+/// Covers what <see cref="SkiaEncoder.ResizeImage"/> does either side of a resize: at matching
+/// dimensions it must not touch the image at all, and sharpening belongs to downscales only.
+/// </summary>
+public class SkiaEncoderResizeTests
+{
+ private static SKBitmap CreateEdgeBitmap(int width, int height)
+ {
+ var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul));
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(new SKColor(40, 60, 80));
+ using var paint = new SKPaint { Color = new SKColor(220, 210, 200) };
+ canvas.DrawRect(SKRect.Create(0, 0, width / 2f, height), paint);
+
+ return bitmap;
+ }
+
+ private static SKImageInfo InfoFor(SKBitmap source, int width, int height)
+ => new SKImageInfo(width, height, source.ColorType, source.AlphaType, source.ColorSpace);
+
+ /// <summary>
+ /// Draws without sharpening, which is what the resize is expected to reduce to when it is not
+ /// downscaling.
+ /// </summary>
+ private static SKBitmap DrawOnly(SKBitmap source, SKImageInfo targetInfo, SKSamplingOptions sampling)
+ {
+ var target = new SKBitmap(targetInfo);
+ using var canvas = new SKCanvas(target);
+ using var paint = new SKPaint();
+ canvas.DrawBitmap(
+ source,
+ SKRect.Create(0, 0, source.Width, source.Height),
+ SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
+ sampling,
+ paint);
+
+ return target;
+ }
+
+ private static void AssertSamePixels(SKBitmap expected, SKBitmap actual)
+ {
+ Assert.Equal(expected.Width, actual.Width);
+ Assert.Equal(expected.Height, actual.Height);
+
+ for (var y = 0; y < expected.Height; y++)
+ {
+ for (var x = 0; x < expected.Width; x++)
+ {
+ Assert.Equal(expected.GetPixel(x, y), actual.GetPixel(x, y));
+ }
+ }
+ }
+
+ [Fact]
+ public void ResizeImage_MatchingDimensions_ReturnsTheImageUntouched()
+ {
+ using var source = CreateEdgeBitmap(16, 16);
+
+ using var result = SkiaEncoder.ResizeImage(source, InfoFor(source, 16, 16));
+ using var resultBitmap = SKBitmap.FromImage(result);
+
+ // Unsharpened, so the edge is still exactly where it was.
+ AssertSamePixels(source, resultBitmap);
+ }
+
+ [Fact]
+ public void ResizeImage_Upscale_DoesNotSharpen()
+ {
+ using var source = CreateEdgeBitmap(8, 8);
+ var targetInfo = InfoFor(source, 24, 24);
+
+ using var result = SkiaEncoder.ResizeImage(source, targetInfo);
+ using var resultBitmap = SKBitmap.FromImage(result);
+ using var expected = DrawOnly(source, targetInfo, SkiaEncoder.UpscaleSamplingOptions);
+
+ AssertSamePixels(expected, resultBitmap);
+ }
+
+ [Fact]
+ public void ResizeImage_Downscale_StillSharpens()
+ {
+ using var source = CreateEdgeBitmap(32, 32);
+ var targetInfo = InfoFor(source, 16, 16);
+
+ using var result = SkiaEncoder.ResizeImage(source, targetInfo);
+ using var resultBitmap = SKBitmap.FromImage(result);
+ using var unsharpened = DrawOnly(source, targetInfo, SkiaEncoder.DefaultSamplingOptions);
+ using var sharpened = DrawOnly(source, targetInfo, SkiaEncoder.DefaultSamplingOptions);
+ SkiaEncoder.SharpenInPlace(sharpened);
+
+ AssertSamePixels(sharpened, resultBitmap);
+ // Guards the test itself: the edge has to be something sharpening actually changes.
+ Assert.NotEqual(unsharpened.GetPixel(8, 8), sharpened.GetPixel(8, 8));
+ }
+}
diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs
new file mode 100644
index 0000000000..72e555cc72
--- /dev/null
+++ b/tests/Jellyfin.Drawing.Skia.Tests/SkiaEncoderSharpenTests.cs
@@ -0,0 +1,73 @@
+using SkiaSharp;
+using Xunit;
+
+namespace Jellyfin.Drawing.Skia.Tests;
+
+public class SkiaEncoderSharpenTests
+{
+ private static SKBitmap CreateBitmap(int width, int height, SKColor fill)
+ {
+ var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul));
+ using var canvas = new SKCanvas(bitmap);
+ canvas.Clear(fill);
+ return bitmap;
+ }
+
+ [Fact]
+ public void SharpenInPlace_UniformImage_IsUnchanged()
+ {
+ // 1.4 * v - 4 * 0.1 * v = v for any uniform value.
+ using var bitmap = CreateBitmap(8, 8, new SKColor(100, 150, 200));
+
+ SkiaEncoder.SharpenInPlace(bitmap);
+
+ for (var y = 0; y < bitmap.Height; y++)
+ {
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ Assert.Equal(new SKColor(100, 150, 200), bitmap.GetPixel(x, y));
+ }
+ }
+ }
+
+ [Fact]
+ public void SharpenInPlace_BrightPixelOnDarkBackground_SharpensEdge()
+ {
+ using var bitmap = CreateBitmap(5, 5, new SKColor(50, 50, 50));
+ bitmap.SetPixel(2, 2, new SKColor(250, 250, 250, 255));
+
+ SkiaEncoder.SharpenInPlace(bitmap);
+
+ // Center: 1.4 * 250 - 0.1 * 4 * 50 = 330 -> clamped to 255.
+ Assert.Equal(new SKColor(255, 255, 255), bitmap.GetPixel(2, 2));
+ // Direct neighbor: 1.4 * 50 - 0.1 * (250 + 3 * 50) = 30.
+ Assert.Equal(new SKColor(30, 30, 30), bitmap.GetPixel(1, 2));
+ // Far corner is only surrounded by background: unchanged.
+ Assert.Equal(new SKColor(50, 50, 50), bitmap.GetPixel(0, 0));
+ }
+
+ [Fact]
+ public void SharpenInPlace_EdgePixels_ClampOutOfBoundsTaps()
+ {
+ // A corner pixel reuses itself for the two out-of-bounds taps:
+ // 1.4 * v - 0.1 * (2 * v + right + down).
+ using var bitmap = CreateBitmap(3, 3, new SKColor(100, 100, 100));
+ bitmap.SetPixel(0, 0, new SKColor(200, 200, 200, 255));
+
+ SkiaEncoder.SharpenInPlace(bitmap);
+
+ // 1.4 * 200 - 0.1 * (200 + 200 + 100 + 100) = 220.
+ Assert.Equal(new SKColor(220, 220, 220), bitmap.GetPixel(0, 0));
+ }
+
+ [Fact]
+ public void SharpenInPlace_UnsupportedColorType_IsLeftUntouched()
+ {
+ using var bitmap = new SKBitmap(new SKImageInfo(4, 4, SKColorType.Gray8, SKAlphaType.Opaque));
+ bitmap.Erase(new SKColor(80, 80, 80));
+
+ SkiaEncoder.SharpenInPlace(bitmap);
+
+ Assert.Equal(80, bitmap.GetPixel(1, 1).Red);
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs
new file mode 100644
index 0000000000..04c7b05c3d
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs
@@ -0,0 +1,130 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Moq.Protected;
+using Xunit;
+using SchedulesDirectProvider = Jellyfin.LiveTv.Listings.SchedulesDirect;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public class SchedulesDirectLineupTests
+{
+ private const string InvalidUserResponse = "{\"response\":\"INVALID_USER\",\"code\":4003,\"message\":\"Invalid user.\",\"serverID\":\"AWS-SD-web.1\"}";
+
+ private static readonly ListingsProviderInfo _info = new() { Username = "user", Password = "password" };
+
+ [Fact]
+ public async Task GetLineups_ValidCredentials_ReturnsLineups()
+ {
+ var tokenResponse = await CreateSuccessfulLogin();
+ using var provider = CreateProvider(tokenResponse, await GetHeadendsResponse());
+
+ var lineups = await provider.GetLineups(_info, "USA", "90210");
+
+ Assert.NotEmpty(lineups);
+ Assert.Contains(lineups, i => string.Equals(i.Id, "USA-OTA-90210", StringComparison.Ordinal));
+ Assert.Contains(lineups, i => string.Equals(i.Name, "Antenna", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task GetLineups_LoginFails_Throws()
+ {
+ using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse());
+
+ // An empty lineup list is indistinguishable from "no lineups for this location", so a
+ // failed login has to surface as an error instead.
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210"));
+ }
+
+ [Fact]
+ public async Task Validate_LoginFails_Throws()
+ {
+ using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse());
+
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.Validate(_info, true, false));
+ }
+
+ [Fact]
+ public async Task Validate_AfterAccountError_RecoversWithoutRestart()
+ {
+ var login = CreateFailedLogin();
+ using var provider = CreateProvider(login, await GetHeadendsResponse());
+
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210"));
+
+ // The account error disables Schedules Direct; saving the provider again is the user
+ // correcting their credentials, and that has to recover without a server restart.
+ login.Status = HttpStatusCode.OK;
+ login.Body = await GetTokenResponse();
+
+ await provider.Validate(_info, true, false);
+
+ Assert.NotEmpty(await provider.GetLineups(_info, "USA", "90210"));
+ }
+
+ private static async Task<Response> CreateSuccessfulLogin()
+ => new() { Status = HttpStatusCode.OK, Body = await GetTokenResponse() };
+
+ private static Response CreateFailedLogin()
+ => new() { Status = HttpStatusCode.BadRequest, Body = InvalidUserResponse };
+
+ private static Task<string> GetTokenResponse()
+ => File.ReadAllTextAsync("Test Data/SchedulesDirect/token_live_response.json", TestContext.Current.CancellationToken);
+
+ private static Task<string> GetHeadendsResponse()
+ => File.ReadAllTextAsync("Test Data/SchedulesDirect/headends_response.json", TestContext.Current.CancellationToken);
+
+ private static SchedulesDirectProvider CreateProvider(Response login, string headendsResponse)
+ {
+ var messageHandler = new Mock<HttpMessageHandler>();
+ messageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
+ .Returns<HttpRequestMessage, CancellationToken>((m, _) =>
+ {
+ var path = m.RequestUri!.AbsolutePath;
+ if (path.EndsWith("/token", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new HttpResponseMessage(login.Status)
+ {
+ Content = new StringContent(login.Body)
+ });
+ }
+
+ if (path.EndsWith("/headends", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(headendsResponse)
+ });
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
+ });
+
+ var httpClientFactory = new Mock<IHttpClientFactory>();
+ httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>()))
+ .Returns(() => new HttpClient(messageHandler.Object));
+
+ var appPaths = new Mock<IApplicationPaths>();
+ appPaths.SetupGet(x => x.CachePath).Returns(Path.GetTempPath());
+
+ return new SchedulesDirectProvider(
+ NullLogger<SchedulesDirectProvider>.Instance,
+ httpClientFactory.Object,
+ appPaths.Object);
+ }
+
+ private sealed class Response
+ {
+ public HttpStatusCode Status { get; set; }
+
+ public string Body { get; set; } = string.Empty;
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs
new file mode 100644
index 0000000000..1d96c5a958
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public sealed class XmlTvListingsProviderCacheTests : IDisposable
+{
+ private const string ChannelId = "3297";
+
+ private readonly string _cachePath = Path.Combine(Path.GetTempPath(), "jellyfin-xmltv-tests-" + Guid.NewGuid().ToString("N"));
+
+ private readonly ListingsProviderInfo _info = new()
+ {
+ Id = "cachetests",
+ Path = "https://example.com/notitle.xml"
+ };
+
+ private bool _downloadsFail;
+ private Exception? _downloadException;
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_cachePath))
+ {
+ Directory.Delete(_cachePath, true);
+ }
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_DownloadFailsAfterASuccess_KeepsUsingTheCachedListings()
+ {
+ var provider = CreateProvider();
+
+ Assert.NotEmpty(await GetPrograms(provider));
+
+ // Age the cached copy out, so the next call goes back to the (now broken) source.
+ var cacheFile = Path.Combine(_cachePath, "xmltv", _info.Id + ".xml");
+ File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddDays(-1));
+ _downloadsFail = true;
+
+ // Losing the listings entirely because a single download failed empties the whole guide.
+ Assert.NotEmpty(await GetPrograms(provider));
+ Assert.True(File.Exists(cacheFile));
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_DownloadTimesOut_DoesNotSurfaceAsCancellation()
+ {
+ var provider = CreateProvider();
+
+ // This is how HttpClient reports its own timeout. Left as an OperationCanceledException it
+ // aborts the guide refresh for every channel and provider instead of only this one.
+ _downloadsFail = true;
+ _downloadException = new TaskCanceledException("timeout", new TimeoutException());
+
+ await Assert.ThrowsAsync<TimeoutException>(() => GetPrograms(provider));
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_ProviderSavedAfterAFailure_DownloadsAgain()
+ {
+ var provider = CreateProvider();
+
+ _downloadsFail = true;
+ await Assert.ThrowsAnyAsync<Exception>(() => GetPrograms(provider));
+
+ // Without clearing the backoff the guide stays empty for an hour, even though saving the
+ // provider deletes the cached file and is the user asking for another attempt.
+ _downloadsFail = false;
+ await provider.Validate(_info, true, true);
+
+ Assert.NotEmpty(await GetPrograms(provider));
+ }
+
+ private async Task<ProgramInfo[]> GetPrograms(XmlTvListingsProvider provider)
+ {
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await provider.GetProgramsAsync(_info, ChannelId, startDate, startDate.AddDays(1), CancellationToken.None);
+
+ return programs.ToArray();
+ }
+
+ private XmlTvListingsProvider CreateProvider()
+ {
+ var messageHandler = new Mock<HttpMessageHandler>();
+ messageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
+ .Returns<HttpRequestMessage, CancellationToken>((m, _) =>
+ {
+ if (_downloadException is not null)
+ {
+ return Task.FromException<HttpResponseMessage>(_downloadException);
+ }
+
+ if (_downloadsFail)
+ {
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1])))
+ });
+ });
+
+ var httpClientFactory = new Mock<IHttpClientFactory>();
+ httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>()))
+ .Returns(() => new HttpClient(messageHandler.Object));
+
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.SetupGet(x => x.CachePath).Returns(_cachePath);
+
+ var config = new Mock<IServerConfigurationManager>();
+ config.SetupGet(x => x.ApplicationPaths).Returns(appPaths.Object);
+ config.SetupGet(x => x.Configuration).Returns(new ServerConfiguration());
+
+ return new XmlTvListingsProvider(
+ config.Object,
+ httpClientFactory.Object,
+ NullLogger<XmlTvListingsProvider>.Instance);
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
index f698edc637..a8ebc8c9b9 100644
--- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
@@ -90,6 +90,49 @@ public class XmlTvListingsProviderTests
AssertXmlTvEtag(program.Etag);
}
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")]
+ [InlineData("https://example.com/no-optional-elements.xml")]
+ public async Task GetProgramsAsync_NoOptionalElements_Success(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = "no-optional-elements-programs",
+ Path = path
+ };
+
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None);
+ var program = Assert.Single(programs.ToList());
+ Assert.Equal("Programme Without Icon Or Rating", program.Name);
+ Assert.False(program.HasImage);
+ Assert.Null(program.ImageUrl);
+ Assert.Null(program.ThumbImageUrl);
+ Assert.Null(program.BackdropImageUrl);
+ Assert.Null(program.OfficialRating);
+ Assert.Null(program.CommunityRating);
+ AssertXmlTvEtag(program.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")]
+ [InlineData("https://example.com/no-optional-elements.xml")]
+ public async Task GetChannels_NoOptionalElements_Success(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = "no-optional-elements-channels",
+ Path = path
+ };
+
+ var channels = await _xmlTvListingsProvider.GetChannels(info, CancellationToken.None);
+ var channel = Assert.Single(channels);
+ Assert.Equal("3297", channel.Id);
+ Assert.Equal("Channel Without Icon", channel.Name);
+ Assert.Equal("3297", channel.Number);
+ Assert.Null(channel.ImageUrl);
+ }
+
[Fact]
public async Task GetProgramsAsync_Etag_SameContentIsStable()
{
diff --git a/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs
new file mode 100644
index 0000000000..4487a5ff2b
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs
@@ -0,0 +1,72 @@
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.TunerHosts;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests
+{
+ public class M3UTunerHostTests
+ {
+ [Theory]
+ // A manifest is not a byte stream, so it must never be offered for direct play.
+ [InlineData("http://example.com/live/1234.m3u8", false)]
+ [InlineData("http://example.com/live/1234.m3u8?token=abc", false)]
+ [InlineData("http://example.com/live/1234.mpd", false)]
+ // Byte streams are unaffected.
+ [InlineData("http://example.com/live/1234.ts", true)]
+ [InlineData("http://example.com/live/1234", true)]
+ public async Task GetChannelStreamMediaSources_ManifestPath_DisablesDirectPlay(string path, bool expectDirectPlay)
+ {
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.Http);
+
+ var host = new TestableM3UTunerHost(
+ Mock.Of<IServerConfigurationManager>(),
+ mediaSourceManager.Object,
+ Mock.Of<ILogger<M3UTunerHost>>(),
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IHttpClientFactory>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<INetworkManager>(),
+ Mock.Of<IStreamHelper>());
+
+ var sources = await host.GetMediaSources(
+ new TunerHostInfo { TunerCount = 0, EnableStreamLooping = false },
+ new ChannelInfo { Path = path });
+
+ Assert.Equal(expectDirectPlay, sources[0].SupportsDirectPlay);
+ }
+
+ private sealed class TestableM3UTunerHost : M3UTunerHost
+ {
+ public TestableM3UTunerHost(
+ IServerConfigurationManager config,
+ IMediaSourceManager mediaSourceManager,
+ ILogger<M3UTunerHost> logger,
+ IFileSystem fileSystem,
+ IHttpClientFactory httpClientFactory,
+ IServerApplicationHost appHost,
+ INetworkManager networkManager,
+ IStreamHelper streamHelper)
+ : base(config, mediaSourceManager, logger, fileSystem, httpClientFactory, appHost, networkManager, streamHelper)
+ {
+ }
+
+ public Task<List<MediaSourceInfo>> GetMediaSources(TunerHostInfo tuner, ChannelInfo channel)
+ => GetChannelStreamMediaSources(tuner, channel, CancellationToken.None);
+ }
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml
new file mode 100644
index 0000000000..e82d00c259
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml
@@ -0,0 +1,10 @@
+<tv date="20221104">
+ <channel id="3297">
+ <display-name>Channel Without Icon</display-name>
+ </channel>
+ <programme channel="3297" start="20221104130000 +0000" stop="20221105235959 +0000">
+ <title lang="en">Programme Without Icon Or Rating</title>
+ <desc lang="en">A programme that only uses the required XMLTV elements.</desc>
+ <category lang="en">sports</category>
+ </programme>
+</tv>
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs
new file mode 100644
index 0000000000..141164815c
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.MediaEncoding.Encoder;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.MediaEncoding.Tests.Encoder;
+
+public class ProcessWrapperTests
+{
+ [Fact]
+ public async Task ExitedProcess_StaysUsableForTheCallerThatStartedIt()
+ {
+ using var process = CreateProcess();
+ using var exitHandled = new ManualResetEventSlim(false);
+
+ using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
+ {
+ // Subscribed after the wrapper, so by the time this is set the wrapper's own handler has
+ // already run: whatever it does to the process has happened.
+ process.Exited += (_, _) => exitHandled.Set();
+
+ process.Start();
+ await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ Assert.True(exitHandled.Wait(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken), "The process never raised Exited.");
+
+ // The caller still owns the process here. Disposing it from the exit handler handed
+ // whoever exited quickest an ObjectDisposedException out of these three lines.
+ var output = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ Assert.Equal("jellyfin", output.Trim());
+
+ Assert.True(wrapper.HasExited);
+ Assert.Equal(3, wrapper.ExitCode);
+ }
+ }
+
+ [Fact]
+ public async Task ExitState_IsReadableBeforeTheExitEventArrives()
+ {
+ using var process = CreateProcess();
+
+ using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
+ {
+ process.Start();
+ await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ // The exit event is raised on the thread pool and can lag behind the wait that just
+ // returned, so neither of these may depend on it having arrived.
+ Assert.True(wrapper.HasExited);
+ Assert.Equal(3, wrapper.ExitCode);
+ }
+ }
+
+ [Fact]
+ public async Task ExitCode_SurvivesDisposal()
+ {
+ using var process = CreateProcess();
+ var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder());
+
+ process.Start();
+ await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ var exitCode = wrapper.ExitCode;
+ wrapper.Dispose();
+
+ Assert.Equal(exitCode, wrapper.ExitCode);
+ Assert.True(wrapper.HasExited);
+ }
+
+ private static MediaEncoder CreateEncoder()
+ => new(
+ Mock.Of<ILogger<MediaEncoder>>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IBlurayExaminer>(),
+ Mock.Of<ILocalizationManager>(),
+ new ConfigurationBuilder().Build(),
+ Mock.Of<IServerConfigurationManager>());
+
+ // Writes to stdout and exits immediately with a non-zero code, standing in for the ffprobe that
+ // rejects a file outright - the process that used to win the race against its own caller.
+ private static Process CreateProcess()
+ {
+ var startInfo = OperatingSystem.IsWindows()
+ ? new ProcessStartInfo("cmd.exe", "/c echo jellyfin & exit 3")
+ : new ProcessStartInfo("/bin/sh", "-c \"printf 'jellyfin\\n'; exit 3\"");
+
+ startInfo.CreateNoWindow = true;
+ startInfo.UseShellExecute = false;
+ startInfo.RedirectStandardOutput = true;
+
+ return new Process { StartInfo = startInfo, EnableRaisingEvents = true };
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs
new file mode 100644
index 0000000000..dfd1eb2e85
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs
@@ -0,0 +1,104 @@
+using System;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Model.Tests.Dlna;
+
+public class StreamBuilderManifestContainerTests
+{
+ [Theory]
+ // A manifest describes a stream instead of carrying one, so it can never be direct played,
+ // even when the client claims to support the container.
+ [InlineData("hls")]
+ [InlineData("hls,applehttp")]
+ [InlineData("applehttp")]
+ [InlineData("dash")]
+ public void GetOptimalVideoStream_ManifestContainer_DoesNotDirectPlay(string container)
+ {
+ var streamInfo = BuildFor(container);
+
+ Assert.NotNull(streamInfo);
+ Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod);
+ }
+
+ [Fact]
+ public void GetOptimalVideoStream_ByteStreamContainer_StillDirectPlays()
+ {
+ var streamInfo = BuildFor("mp4");
+
+ Assert.NotNull(streamInfo);
+ Assert.Equal(PlayMethod.DirectPlay, streamInfo.PlayMethod);
+ }
+
+ private static StreamInfo? BuildFor(string container)
+ {
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = "test-source",
+ Path = "http://example.com/live/channel",
+ Protocol = MediaProtocol.Http,
+ Container = container,
+ SupportsDirectPlay = true,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true,
+ IsInfiniteStream = true,
+ IsRemote = true,
+ MediaStreams =
+ [
+ new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264" },
+ new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac" }
+ ]
+ };
+
+ var profile = new DeviceProfile
+ {
+ Name = "Manifest aware client",
+ DirectPlayProfiles =
+ [
+ new DirectPlayProfile
+ {
+ Type = DlnaProfileType.Video,
+ Container = "mp4,hls,applehttp,dash",
+ VideoCodec = "h264",
+ AudioCodec = "aac"
+ }
+ ],
+ TranscodingProfiles =
+ [
+ new TranscodingProfile
+ {
+ Type = DlnaProfileType.Video,
+ Context = EncodingContext.Streaming,
+ Protocol = MediaStreamProtocol.hls,
+ Container = "ts",
+ VideoCodec = "h264",
+ AudioCodec = "aac"
+ }
+ ]
+ };
+
+ var options = new MediaOptions
+ {
+ ItemId = new Guid("11D229B7-2D48-4B95-9F9B-49F6AB75E613"),
+ MediaSourceId = mediaSource.Id,
+ MediaSources = [mediaSource],
+ DeviceId = "test-deviceId",
+ Profile = profile,
+ AllowAudioStreamCopy = true,
+ AllowVideoStreamCopy = true,
+ EnableDirectStream = false // This is disabled in server
+ };
+
+ var transcodeSupport = new Mock<ITranscoderSupport>();
+
+ return new StreamBuilder(transcodeSupport.Object, new NullLogger<StreamBuilderManifestContainerTests>())
+ .GetOptimalVideoStream(options);
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs
new file mode 100644
index 0000000000..f264e5a019
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs
@@ -0,0 +1,129 @@
+using Jellyfin.Data.Enums;
+using MediaBrowser.Model.Entities;
+using Xunit;
+
+namespace Jellyfin.Model.Tests.Entities;
+
+public class MediaStreamVideoRangeTests
+{
+ [Theory]
+ [InlineData(7, 6, "smpte2084", false, VideoRangeType.DOVIWithEL)]
+ [InlineData(7, 6, "smpte2084", true, VideoRangeType.DOVIWithELHDR10Plus)]
+ [InlineData(8, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)]
+ [InlineData(8, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)]
+ [InlineData(8, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)]
+ [InlineData(10, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)]
+ [InlineData(10, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)]
+ [InlineData(10, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)]
+ [InlineData(8, 1, "SMPTE2084", false, VideoRangeType.DOVIWithHDR10)]
+ [InlineData(8, 4, "ARIB-STD-B67", false, VideoRangeType.DOVIWithHLG)]
+ public void GetVideoColorRange_ValidDovi_PreservesRangeType(
+ int profile, int compatibilityId, string transfer, bool hdr10Plus, VideoRangeType expected)
+ {
+ var stream = CreateDovi(profile, compatibilityId, "BT2020NC", transfer, "BT2020", hdr10Plus);
+
+ Assert.Equal((VideoRange.HDR, expected), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData("bt709", "bt709", "bt709", VideoRange.SDR)]
+ [InlineData("bt2020nc", "bt709", "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", null, "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", "", "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", "unknown", "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", "bt2020-10", "bt2020", VideoRange.SDR)]
+ [InlineData(null, null, null, VideoRange.SDR)]
+ [InlineData("bt709", "smpte2084", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "smpte2084", "bt709", VideoRange.HDR)]
+ [InlineData(null, "smpte2084", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "smpte2084", null, VideoRange.HDR)]
+ [InlineData("bt709", "arib-std-b67", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "arib-std-b67", "bt709", VideoRange.HDR)]
+ [InlineData(null, "arib-std-b67", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "arib-std-b67", null, VideoRange.HDR)]
+ public void GetVideoColorRange_InvalidDoviColors_UsesBaseLayerRange(
+ string? space, string? transfer, string? primaries, VideoRange expected)
+ {
+ // Cover every HDR-compatible DV profile, including the HDR10+ variants.
+ foreach (var (profile, compatibilityId) in new[] { (7, 6), (8, 1), (8, 4), (10, 1), (10, 4) })
+ {
+ foreach (var hdr10Plus in new[] { false, true })
+ {
+ var stream = CreateDovi(profile, compatibilityId, space, transfer, primaries, hdr10Plus);
+
+ Assert.Equal(expected, stream.VideoRange);
+ Assert.Equal(VideoRangeType.DOVIInvalid, stream.VideoRangeType);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData(7, 6, "arib-std-b67")]
+ [InlineData(8, 1, "arib-std-b67")]
+ [InlineData(8, 4, "smpte2084")]
+ [InlineData(10, 1, "arib-std-b67")]
+ [InlineData(10, 4, "smpte2084")]
+ public void GetVideoColorRange_WrongHdrTransfer_InvalidButStillHdr(int profile, int compatibilityId, string transfer)
+ {
+ var stream = CreateDovi(profile, compatibilityId, "bt2020nc", transfer, "bt2020", true);
+
+ Assert.Equal((VideoRange.HDR, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData(5, 0, null, VideoRange.HDR, VideoRangeType.DOVI)]
+ [InlineData(10, 0, null, VideoRange.HDR, VideoRangeType.DOVI)]
+ [InlineData(8, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)]
+ [InlineData(10, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)]
+ public void GetVideoColorRange_OtherDoviProfiles_PreservesClassification(
+ int profile, int compatibilityId, string? transfer, VideoRange range, VideoRangeType rangeType)
+ {
+ var stream = CreateDovi(profile, compatibilityId, "bt709", transfer, "bt709", false);
+
+ Assert.Equal((range, rangeType), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData(8, null, VideoRange.SDR)]
+ [InlineData(8, "bt709", VideoRange.SDR)]
+ [InlineData(8, "smpte2084", VideoRange.HDR)]
+ [InlineData(10, null, VideoRange.SDR)]
+ [InlineData(10, "arib-std-b67", VideoRange.HDR)]
+ public void GetVideoColorRange_InvalidCompatibilityId_UsesBaseLayerRange(int profile, string? transfer, VideoRange expected)
+ {
+ var stream = CreateDovi(profile, 6, "bt2020nc", transfer, "bt2020", false);
+
+ Assert.Equal((expected, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData("bt709", false, VideoRange.SDR, VideoRangeType.SDR)]
+ [InlineData(null, false, VideoRange.SDR, VideoRangeType.SDR)]
+ [InlineData("smpte2084", false, VideoRange.HDR, VideoRangeType.HDR10)]
+ [InlineData("smpte2084", true, VideoRange.HDR, VideoRangeType.HDR10Plus)]
+ [InlineData("arib-std-b67", false, VideoRange.HDR, VideoRangeType.HLG)]
+ public void GetVideoColorRange_WithoutDovi_PreservesClassification(
+ string? transfer, bool hdr10Plus, VideoRange range, VideoRangeType rangeType)
+ {
+ var stream = new MediaStream { Type = MediaStreamType.Video, ColorTransfer = transfer, Hdr10PlusPresentFlag = hdr10Plus };
+
+ Assert.Equal((range, rangeType), stream.GetVideoColorRange());
+ stream.Type = MediaStreamType.Audio;
+ Assert.Equal((VideoRange.Unknown, VideoRangeType.Unknown), stream.GetVideoColorRange());
+ }
+
+ private static MediaStream CreateDovi(int profile, int compatibilityId, string? space, string? transfer, string? primaries, bool hdr10Plus)
+ => new()
+ {
+ Type = MediaStreamType.Video,
+ DvProfile = profile,
+ DvBlSignalCompatibilityId = compatibilityId,
+ RpuPresentFlag = 1,
+ BlPresentFlag = 1,
+ ElPresentFlag = profile == 7 ? 1 : 0,
+ ColorSpace = space,
+ ColorTransfer = transfer,
+ ColorPrimaries = primaries,
+ Hdr10PlusPresentFlag = hdr10Plus
+ };
+}
diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs
index 7e708c681d..4236749423 100644
--- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs
@@ -74,6 +74,9 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("Season 5/S05E23 11-59 [HDTV-1080p][x265 AC3].mkv", null)]
[InlineData("Season 5/S05E23 11-59 [HDTV-1080p][HEVC AC3].mkv", null)]
[InlineData("Season 1/S01E01 1-23-45 [Bluray-1080p][AV1 Opus].mkv", null)]
+ // Episode markers in the episode title must not be read as an episode range
+ [InlineData("Season 03/Star Trek Enterprise (2001) - S03E21 - E2 (1080p BluRay x265).mkv", null)]
+ [InlineData("Season 02/Series Name (2001) - S02E10 - E5 [WEBRip-1080p].mkv", null)]
public void TestGetEndingEpisodeNumberFromFile(string filename, int? endingEpisodeNumber)
{
var result = _episodePathParser.Parse(filename, false);
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
index b81b7934cd..023c6cb2fa 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
@@ -20,6 +20,11 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("/some/path/The Show s02e10 720p hdtv", "The Show")]
[InlineData("/some/path/The Show s02e10 the episode 720p hdtv", "The Show")]
[InlineData("/some/path/1923 (2022)", "1923")]
+ // A dotted acronym keeps its dots when it follows words, whether they are space or dot separated
+ [InlineData("/some/path/Marvel's Agents of S.H.I.E.L.D.", "Marvel's Agents of S.H.I.E.L.D.")]
+ [InlineData("Marvel's.Agents.of.S.H.I.E.L.D.", "Marvel's Agents of S.H.I.E.L.D.")]
+ [InlineData("The.Show.S.H.O.W", "The Show S.H.O.W")]
+ [InlineData("/some/path/Dawson's Creek", "Dawson's Creek")]
public void SeriesResolverResolveTest(string path, string name)
{
var res = SeriesResolver.Resolve(_namingOptions, path);
diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
index b29c64f50d..62c1cf25d7 100644
--- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs
@@ -1130,5 +1130,45 @@ namespace Jellyfin.Naming.Tests.Video
Assert.Equal(2, result[0].Files.Count);
Assert.Single(result[0].AlternateVersions);
}
+
+ [Fact]
+ public void TestMultiVersionEpisodeAbsoluteNumberingWithNumberInSeriesTitle()
+ {
+ // Every one of these parses as episode 2, because the expressions read the "2" of the
+ // series title as an absolute episode number. They are distinct episodes all the same.
+ var files = new[]
+ {
+ "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 01 - The Memory of a Summer (b6f40849).mkv",
+ "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 02 - Heart Pain Killer (d8c0896c).mkv",
+ "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 03 - Translucent Chord (4ecce3dd).mkv",
+ "/anime/IS Infinite Stratos 2/IS Infinite Stratos 2 - 04 - The Mysterious Lady (837a1909).mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(4, result.Count);
+ Assert.All(result, r => Assert.Empty(r.AlternateVersions));
+ }
+
+ [Fact]
+ public void TestMultiVersionEpisodeAbsoluteNumberingDontCollapse()
+ {
+ // Plain absolute numbering: no season number is available, so the files stay separate.
+ var files = new[]
+ {
+ "/anime/Bleach/Bleach - 001 - The Day I Became a Shinigami.mkv",
+ "/anime/Bleach/Bleach - 002 - The Shinigami's Work.mkv",
+ "/anime/Bleach/Bleach - 003 - The Older Brother's Wish.mkv"
+ };
+
+ var result = _videoListResolver.Resolve(
+ files.Select(i => VideoResolver.Resolve(i, false, _namingOptions)).OfType<VideoFileInfo>().ToList(),
+ collectionType: CollectionType.tvshows).ToList();
+
+ Assert.Equal(3, result.Count);
+ Assert.All(result, r => Assert.Empty(r.AlternateVersions));
+ }
}
}
diff --git a/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs b/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs
new file mode 100644
index 0000000000..f7f29d9768
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Books/ComicBookInfoProviderTests.cs
@@ -0,0 +1,143 @@
+using System;
+using System.IO;
+using System.IO.Compression;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.Books.ComicBookInfo;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Books;
+
+public sealed class ComicBookInfoProviderTests : IDisposable
+{
+ private const string ValidComment = """
+ {"appID":"test","ComicBookInfo/1.0":{"series":"Jungle Juice","title":"Episode 36","issue":175}}
+ """;
+
+ private readonly string _directory;
+
+ public ComicBookInfoProviderTests()
+ {
+ _directory = Path.Combine(Path.GetTempPath(), "jf-cbz-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_directory);
+ }
+
+ public void Dispose()
+ {
+ Directory.Delete(_directory, true);
+ }
+
+ [Theory]
+ [InlineData(null)] // archive written without ever touching Comment
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task ReadMetadata_EmptyArchiveComment_SkipsWithoutDeserializing(string? comment)
+ {
+ var logger = new Mock<ILogger<ComicBookInfoProvider>>();
+ var path = CreateArchive(comment);
+ var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object);
+
+ var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None);
+
+ Assert.False(result.HasMetadata);
+ VerifyLogged(logger, LogLevel.Debug, "missing ComicBookInfo in archive comment");
+ VerifyNothingLoggedAbove(logger, LogLevel.Debug);
+ }
+
+ [Fact]
+ public async Task ReadMetadata_ArchiveCommentIsNotComicBookInfo_SkipsWithoutError()
+ {
+ var logger = new Mock<ILogger<ComicBookInfoProvider>>();
+ var path = CreateArchive("Created by some packer");
+ var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object);
+
+ var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None);
+
+ Assert.False(result.HasMetadata);
+ VerifyLogged(logger, LogLevel.Debug, "archive comment is not valid ComicBookInfo metadata");
+ VerifyNothingLoggedAbove(logger, LogLevel.Debug);
+ }
+
+ [Fact]
+ public async Task ReadMetadata_ValidComicBookInfoComment_ReturnsMetadata()
+ {
+ var logger = new Mock<ILogger<ComicBookInfoProvider>>();
+ var path = CreateArchive(ValidComment);
+ var provider = new ComicBookInfoProvider(CreateFileSystem(path), logger.Object);
+
+ var result = await provider.ReadMetadata(new ItemInfo(new Book { Path = path }), Mock.Of<IDirectoryService>(), CancellationToken.None);
+
+ Assert.True(result.HasMetadata);
+ Assert.NotNull(result.Item);
+ Assert.Equal("Episode 36", result.Item.Name);
+ Assert.Equal("Jungle Juice", result.Item.SeriesName);
+ Assert.Equal(175, result.Item.IndexNumber);
+ VerifyNothingLoggedAbove(logger, LogLevel.Debug);
+ }
+
+ private static void VerifyLogged(Mock<ILogger<ComicBookInfoProvider>> logger, LogLevel level, string message)
+ {
+ logger.Verify(
+ x => x.Log(
+ level,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains(message, StringComparison.Ordinal)),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+ }
+
+ private static void VerifyNothingLoggedAbove(Mock<ILogger<ComicBookInfoProvider>> logger, LogLevel level)
+ {
+ // a comment that holds no ComicBookInfo is normal, so it must not reach the log of a default install
+ logger.Verify(
+ x => x.Log(
+ It.Is<LogLevel>(actual => actual > level),
+ It.IsAny<EventId>(),
+ It.IsAny<It.IsAnyType>(),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Never);
+ }
+
+ private static IFileSystem CreateFileSystem(string path)
+ {
+ var fileSystem = new Mock<IFileSystem>();
+ fileSystem.Setup(x => x.GetFileSystemInfo(path))
+ .Returns(new FileSystemMetadata
+ {
+ Exists = true,
+ FullName = path,
+ Name = Path.GetFileName(path),
+ Extension = ".cbz",
+ IsDirectory = false
+ });
+
+ return fileSystem.Object;
+ }
+
+ private string CreateArchive(string? comment)
+ {
+ var path = Path.Combine(_directory, Guid.NewGuid().ToString("N") + ".cbz");
+
+ using (var stream = File.Create(path))
+ using (var archive = new ZipArchive(stream, ZipArchiveMode.Create))
+ {
+ if (comment is not null)
+ {
+ archive.Comment = comment;
+ }
+
+ var entry = archive.CreateEntry("ComicInfo.xml");
+ using var writer = new StreamWriter(entry.Open());
+ writer.Write("<ComicInfo />");
+ }
+
+ return path;
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
index 1ec859223e..459973acba 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
@@ -557,6 +557,47 @@ namespace Jellyfin.Providers.Tests.Manager
Assert.Equal(expectedToUpdate, result.UpdateType.HasFlag(ItemUpdateType.ImageUpdate));
}
+ [Fact]
+ public async Task RefreshImages_ProviderDynamicThrows_CountsAFailure()
+ {
+ var item = GetItemWithImages(ImageType.Primary, 0, false);
+ var libraryOptions = GetLibraryOptions(item, ImageType.Primary, 1);
+
+ var dynamicProvider = new Mock<IDynamicImageProvider>(MockBehavior.Strict);
+ dynamicProvider.Setup(rp => rp.Name).Returns("MockDynamicProvider");
+ dynamicProvider.Setup(rp => rp.GetSupportedImages(item))
+ .Returns(new[] { ImageType.Primary });
+ dynamicProvider.Setup(rp => rp.GetImage(item, ImageType.Primary, It.IsAny<CancellationToken>()))
+ .ThrowsAsync(new InvalidOperationException("provider is broken"));
+
+ var itemImageProvider = GetItemImageProvider(null, new Mock<IFileSystem>());
+ var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { dynamicProvider.Object }, new ImageRefreshOptions(Mock.Of<IDirectoryService>()), CancellationToken.None);
+
+ // Without this the caller stamps DateLastRefreshed and never asks this provider again.
+ Assert.Equal(1, result.Failures);
+ }
+
+ [Fact]
+ public async Task RefreshImages_ProviderRemoteThrows_CountsAFailure()
+ {
+ var item = GetItemWithImages(ImageType.Primary, 0, false);
+ var libraryOptions = GetLibraryOptions(item, ImageType.Primary, 1);
+
+ var remoteProvider = new Mock<IRemoteImageProvider>(MockBehavior.Strict);
+ remoteProvider.Setup(rp => rp.Name).Returns("MockRemoteProvider");
+ remoteProvider.Setup(rp => rp.GetSupportedImages(item))
+ .Returns(new[] { ImageType.Primary });
+
+ var providerManager = new Mock<IProviderManager>(MockBehavior.Strict);
+ providerManager.Setup(pm => pm.GetAvailableRemoteImages(It.IsAny<BaseItem>(), It.IsAny<RemoteImageQuery>(), It.IsAny<CancellationToken>()))
+ .ThrowsAsync(new HttpRequestException("unreachable"));
+
+ var itemImageProvider = GetItemImageProvider(providerManager.Object, new Mock<IFileSystem>());
+ var result = await itemImageProvider.RefreshImages(item, libraryOptions, new List<IImageProvider> { remoteProvider.Object }, new ImageRefreshOptions(Mock.Of<IDirectoryService>()), CancellationToken.None);
+
+ Assert.Equal(1, result.Failures);
+ }
+
private static ItemImageProvider GetItemImageProvider(IProviderManager? providerManager, Mock<IFileSystem>? mockFileSystem)
{
// strict to ensure this isn't accidentally used where a prepared mock is intended
diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs
index 3b4d6fc9bb..acf8de4366 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs
@@ -287,6 +287,161 @@ namespace Jellyfin.Providers.Tests.Manager
}
}
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task RefreshMetadata_CustomProviderThrew_LeavesRefreshDateAlone(bool providerThrows)
+ {
+ var item = new TestItem
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Item",
+ PreferredMetadataLanguage = "en",
+ PreferredMetadataCountryCode = "US",
+ DateLastRefreshed = DateTime.UtcNow.AddDays(-60),
+ DateLastSaved = DateTime.UtcNow.AddDays(-60)
+ };
+ item.PresentationUniqueKey = item.CreatePresentationUniqueKey();
+
+ var stampBefore = item.DateLastRefreshed;
+
+ // Stands in for the probe provider, whose only other change monitor is the file's
+ // modification time: if a throw is stamped as a completed refresh the item is never revisited.
+ var provider = new Mock<ICustomMetadataProvider<TestItem>>(MockBehavior.Loose);
+ provider.Setup(p => p.Name).Returns("Throwing Provider");
+ provider.Setup(p => p.FetchAsync(It.IsAny<TestItem>(), It.IsAny<MetadataRefreshOptions>(), It.IsAny<CancellationToken>()))
+ .Returns(providerThrows
+ ? Task.FromException<ItemUpdateType>(new InvalidOperationException("probe failed"))
+ : Task.FromResult(ItemUpdateType.None));
+
+ var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose);
+ libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+
+ var providerManager = new Mock<IProviderManager>(MockBehavior.Loose);
+ providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>()))
+ .Returns(Array.Empty<IImageProvider>());
+ providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
+ .Returns(new[] { (IMetadataProvider<TestItem>)provider.Object });
+ providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
+ .Returns(Array.Empty<IMetadataSaver>());
+
+ var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose);
+ itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true);
+
+ var service = new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object);
+
+ await service.RefreshMetadata(
+ item,
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ImageRefreshMode = MetadataRefreshMode.FullRefresh
+ },
+ CancellationToken.None).ConfigureAwait(true);
+
+ Assert.Equal(providerThrows, item.DateLastRefreshed == stampBefore);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task RefreshMetadata_ImageProviderThrew_LeavesRefreshDateAlone(bool providerThrows)
+ {
+ var item = NewStampedTestItem();
+ var stampBefore = item.DateLastRefreshed;
+
+ var imageProvider = new Mock<IDynamicImageProvider>(MockBehavior.Loose);
+ imageProvider.Setup(p => p.Name).Returns("Throwing Image Provider");
+ imageProvider.Setup(p => p.GetSupportedImages(It.IsAny<BaseItem>())).Returns(new[] { ImageType.Primary });
+ imageProvider.Setup(p => p.GetImage(It.IsAny<BaseItem>(), ImageType.Primary, It.IsAny<CancellationToken>()))
+ .Returns(providerThrows
+ ? Task.FromException<DynamicImageResponse>(new InvalidOperationException("image fetch failed"))
+ : Task.FromResult(new DynamicImageResponse { HasImage = false }));
+
+ var providerManager = NewProviderManager(imageProviders: [imageProvider.Object]);
+
+ var service = NewService(providerManager);
+
+ await service.RefreshMetadata(
+ item,
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ImageRefreshMode = MetadataRefreshMode.FullRefresh
+ },
+ CancellationToken.None).ConfigureAwait(true);
+
+ Assert.Equal(providerThrows, item.DateLastRefreshed == stampBefore);
+ }
+
+ [Fact]
+ public async Task RefreshMetadata_LocalImageValidationThrew_LeavesRefreshDateAlone()
+ {
+ var item = NewStampedTestItem();
+ var stampBefore = item.DateLastRefreshed;
+
+ // A throw here skips the remote image stage altogether, so no image work happened at all.
+ var localImageProvider = new Mock<ILocalImageProvider>(MockBehavior.Loose);
+ localImageProvider.Setup(p => p.Name).Returns("Throwing Local Image Provider");
+ localImageProvider.Setup(p => p.GetImages(It.IsAny<BaseItem>(), It.IsAny<IDirectoryService>()))
+ .Throws(new UnauthorizedAccessException("metadata folder is not readable"));
+
+ var providerManager = NewProviderManager(imageProviders: [localImageProvider.Object]);
+
+ var service = NewService(providerManager);
+
+ await service.RefreshMetadata(
+ item,
+ new MetadataRefreshOptions(Mock.Of<IDirectoryService>())
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
+ ImageRefreshMode = MetadataRefreshMode.FullRefresh
+ },
+ CancellationToken.None).ConfigureAwait(true);
+
+ Assert.Equal(stampBefore, item.DateLastRefreshed);
+ }
+
+ private static TestItem NewStampedTestItem()
+ {
+ var item = new TestItem
+ {
+ Id = Guid.NewGuid(),
+ Name = "Test Item",
+ PreferredMetadataLanguage = "en",
+ PreferredMetadataCountryCode = "US",
+ DateLastRefreshed = DateTime.UtcNow.AddDays(-60),
+ DateLastSaved = DateTime.UtcNow.AddDays(-60)
+ };
+ item.PresentationUniqueKey = item.CreatePresentationUniqueKey();
+ return item;
+ }
+
+ private static Mock<IProviderManager> NewProviderManager(
+ IMetadataProvider<TestItem>[]? metadataProviders = null,
+ IImageProvider[]? imageProviders = null)
+ {
+ var providerManager = new Mock<IProviderManager>(MockBehavior.Loose);
+ providerManager.Setup(p => p.GetImageProviders(It.IsAny<BaseItem>(), It.IsAny<ImageRefreshOptions>()))
+ .Returns(imageProviders ?? Array.Empty<IImageProvider>());
+ providerManager.Setup(p => p.GetMetadataProviders<TestItem>(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
+ .Returns(metadataProviders ?? Array.Empty<IMetadataProvider<TestItem>>());
+ providerManager.Setup(p => p.GetMetadataSavers(It.IsAny<BaseItem>(), It.IsAny<LibraryOptions>()))
+ .Returns(Array.Empty<IMetadataSaver>());
+ return providerManager;
+ }
+
+ private static TestItemMetadataService NewService(Mock<IProviderManager> providerManager)
+ {
+ var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose);
+ libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny<BaseItem>())).Returns(new LibraryOptions());
+
+ var itemRepository = new Mock<IItemRepository>(MockBehavior.Loose);
+ itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny<Guid>())).ReturnsAsync(true);
+
+ return new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object);
+ }
+
/// <summary>
/// Stands in for a real item so the refresh stays off the shared BaseItem statics, which other
/// test classes in this assembly overwrite while xUnit runs them in parallel.
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 5749944fcd..d3b82cefcd 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@@ -377,6 +378,116 @@ namespace Jellyfin.Providers.Tests.Manager
GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, ownedItem: true);
}
+ [Fact]
+ public async Task QueueRefresh_ManyItemsQueuedFromManyThreads_ProcessesEveryOne()
+ {
+ const int ItemCount = 2000;
+
+ var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray();
+ var processed = new ConcurrentBag<Guid>();
+ var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>()))
+ .Returns((Guid id) =>
+ {
+ // Returning null drains the entry without the whole refresh machinery.
+ processed.Add(id);
+ if (processed.Count == ItemCount)
+ {
+ allProcessed.TrySetResult();
+ }
+
+ return null;
+ });
+
+ using var providerManager = GetProviderManager(libraryManager: libraryManager.Object);
+
+ await Parallel.ForEachAsync(
+ queued,
+ TestContext.Current.CancellationToken,
+ (id, _) =>
+ {
+ providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal);
+ return ValueTask.CompletedTask;
+ });
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(30));
+
+ try
+ {
+ await allProcessed.Task.WaitAsync(timeout.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Fall through so the assertions report what was lost.
+ }
+
+ Assert.Empty(providerManager.GetRefreshQueue());
+ Assert.Equal(queued.Order().ToArray(), processed.Order().ToArray());
+ }
+
+ [Fact]
+ public async Task QueueRefresh_RefreshCancelsForItsOwnReasons_KeepsDrainingTheQueue()
+ {
+ // A provider timeout arrives as an OperationCanceledException, indistinguishable from
+ // a shutdown; treating it as one would strand the rest of the queue.
+ const int ItemCount = 200;
+
+ var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray();
+ var processed = new ConcurrentBag<Guid>();
+ var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var allQueued = new ManualResetEventSlim(false);
+ var cancelledOnce = false;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>()))
+ .Returns((Guid id) =>
+ {
+ if (!cancelledOnce)
+ {
+ cancelledOnce = true;
+
+ // Hold the first entry until the whole batch is queued.
+ allQueued.Wait(TimeSpan.FromSeconds(30));
+ throw new OperationCanceledException("provider timed out");
+ }
+
+ processed.Add(id);
+ if (processed.Count == ItemCount - 1)
+ {
+ allProcessed.TrySetResult();
+ }
+
+ return null;
+ });
+
+ using var providerManager = GetProviderManager(libraryManager: libraryManager.Object);
+
+ foreach (var id in queued)
+ {
+ providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal);
+ }
+
+ allQueued.Set();
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(30));
+
+ try
+ {
+ await allProcessed.Task.WaitAsync(timeout.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Fall through so the assertions report what was stranded.
+ }
+
+ Assert.Empty(providerManager.GetRefreshQueue());
+ Assert.Equal(ItemCount - 1, processed.Count);
+ }
+
private static void GetMetadataProviders_CanRefreshMetadata_Tester(
string providerType,
bool expected,
@@ -554,15 +665,22 @@ namespace Jellyfin.Providers.Tests.Manager
private static ProviderManager GetProviderManager(
ServerConfiguration? serverConfiguration = null,
LibraryOptions? libraryOptions = null,
- IBaseItemManager? baseItemManager = null)
+ IBaseItemManager? baseItemManager = null,
+ ILibraryManager? libraryManager = null)
{
var serverConfigurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Strict);
serverConfigurationManager.Setup(i => i.Configuration)
.Returns(serverConfiguration ?? new ServerConfiguration());
- var libraryManager = new Mock<ILibraryManager>(MockBehavior.Strict);
- libraryManager.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
- .Returns(libraryOptions ?? new LibraryOptions());
+ if (libraryManager is null)
+ {
+ var libraryManagerMock = new Mock<ILibraryManager>(MockBehavior.Strict);
+ libraryManagerMock.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
+ .Returns(libraryOptions ?? new LibraryOptions());
+ libraryManagerMock.Setup(i => i.GetCollectionFolders(It.IsAny<BaseItem>()))
+ .Returns(new List<Folder>());
+ libraryManager = libraryManagerMock.Object;
+ }
var providerManager = new ProviderManager(
Mock.Of<IHttpClientFactory>(),
@@ -572,7 +690,7 @@ namespace Jellyfin.Providers.Tests.Manager
_logger,
Mock.Of<IFileSystem>(),
Mock.Of<IServerApplicationPaths>(),
- libraryManager.Object,
+ libraryManager,
baseItemManager!,
Mock.Of<ILyricManager>(),
Mock.Of<IMemoryCache>(),
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
index 876f18741f..ce451861ef 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
@@ -179,6 +179,146 @@ public class MediaInfoResolverTests
Assert.Empty(streams);
}
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubPair_OnlyReturnsIdxFile()
+ {
+ // VobSub (.sub) payloads only carry per-track language metadata when read
+ // alongside their paired .idx index file. When both are present, only the
+ // .idx file should be returned so it (not the raw .sub) gets probed.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(Array.Empty<string>());
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxWithoutMatchingSub_DoesNotReturnIdxFile()
+ {
+ // An .idx file with no paired .sub cannot be probed for anything, so it must be
+ // left out entirely rather than surfaced as a doomed-to-fail probe candidate.
+ // Surfacing it anyway would also make it "exist" from Jellyfin's perspective
+ // even after the real .sub is deleted, preventing stale subtitle stream data
+ // from ever being cleared on a rescan.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = GetDirectoryServiceForExternalFile("My.Video.idx");
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
+
+ Assert.Empty(streams);
+ }
+
+ [Fact]
+ public void GetExternalFiles_StandaloneSubWithoutIdx_StillReturnsSubFile()
+ {
+ // Guards against the .idx/.sub pairing suppression firing when there is no
+ // .idx sidecar at all - a lone .sub file must still be returned.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = GetDirectoryServiceForExternalFile("My.Video.sub");
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubInDifferentDirectories_DoesNotPair()
+ {
+ // A same-named .idx and .sub split across the video folder and the internal
+ // metadata folder cannot be paired by ffprobe (it only looks next to the .idx),
+ // so the .sub must still be returned, but the orphaned .idx (no sibling .sub in
+ // its own directory) must be left out since it cannot be probed.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { MetadataDirectoryPath + "/My.Video.idx" });
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubWithMatchingLanguageFlag_SuppressesSub()
+ {
+ // A .idx/.sub pair sharing the same filename flags (e.g. a language token) should
+ // still pair and suppress the .sub, just like an unflagged pair.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.en.idx", VideoDirectoryPath + "/My.Video.en.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(Array.Empty<string>());
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubWithMismatchedNames_DoesNotPair()
+ {
+ // An .idx and .sub with different basenames (e.g. differing filename flags) are not
+ // a pair ffprobe would resolve. The .sub must still be returned, but the orphaned
+ // .idx (no same-named sibling .sub) must be left out since it cannot be probed.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.en.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(Array.Empty<string>());
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
[Theory]
[InlineData("https://url.com/My.Video.mkv")]
[InlineData(VideoDirectoryPath)] // valid but no files found for this test
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs
new file mode 100644
index 0000000000..cd7ae0f525
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/ProbeProviderTests.cs
@@ -0,0 +1,113 @@
+using Emby.Naming.Common;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Subtitles;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Providers.MediaInfo;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.MediaInfo;
+
+public class ProbeProviderTests
+{
+ private readonly ProbeProvider _probeProvider;
+
+ public ProbeProviderTests()
+ {
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsAny<string>()))
+ .Returns(MediaProtocol.File);
+
+ // prep BaseItem and Video for calls made that expect managers
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+ Video.RecordingsManager = Mock.Of<IRecordingsManager>();
+
+ _probeProvider = new ProbeProvider(
+ mediaSourceManager.Object,
+ Mock.Of<IMediaEncoder>(),
+ Mock.Of<IBlurayExaminer>(),
+ Mock.Of<ILocalizationManager>(),
+ Mock.Of<IChapterManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ISubtitleManager>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IFileSystem>(),
+ NullLoggerFactory.Instance,
+ new NamingOptions(),
+ Mock.Of<ILyricManager>(),
+ Mock.Of<IMediaAttachmentRepository>(),
+ Mock.Of<IMediaStreamRepository>());
+ }
+
+ [Fact]
+ public void HasChanged_NeverProbedVideo_ReturnsTrue()
+ {
+ // A probe that threw leaves the item like this while the refresh is stamped as done, and the
+ // file's modification time never changes afterwards, so nothing else would ask for a retry.
+ var item = new Episode { Path = "/media/show/S01E01.mkv" };
+
+ Assert.True(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Fact]
+ public void HasChanged_NeverProbedAudio_ReturnsTrue()
+ {
+ var item = new Audio { Path = "/media/music/track.flac" };
+
+ Assert.True(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Theory]
+ [InlineData(12345L, null)]
+ [InlineData(null, 2480000)]
+ [InlineData(12345L, 2480000)]
+ public void HasChanged_ProbedVideo_ReturnsFalse(long? runTimeTicks, int? totalBitrate)
+ {
+ var item = new Episode
+ {
+ Path = "/media/show/S01E01.mkv",
+ RunTimeTicks = runTimeTicks,
+ TotalBitrate = totalBitrate
+ };
+
+ Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Fact]
+ public void HasChanged_VirtualItemWithoutMediaInfo_ReturnsFalse()
+ {
+ var item = new Episode { Path = "/media/show/S01E01.mkv", IsVirtualItem = true };
+
+ Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Fact]
+ public void HasChanged_PlaceHolderWithoutMediaInfo_ReturnsFalse()
+ {
+ var item = new Episode { Path = "/media/show/S01E01.disc", IsPlaceHolder = true };
+
+ Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+
+ [Fact]
+ public void HasChanged_ShortcutWithoutMediaInfo_ReturnsFalse()
+ {
+ // A .strm is only probed when remote content probing is enabled, so an empty one is expected.
+ var item = new Episode { Path = "/media/show/S01E01.strm", IsShortcut = true };
+
+ Assert.False(_probeProvider.HasChanged(item, Mock.Of<IDirectoryService>()));
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs
new file mode 100644
index 0000000000..a73ed61a75
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Playlists/PlaylistItemsProviderTests.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Providers.Playlists;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Playlists;
+
+public sealed class PlaylistItemsProviderTests : IDisposable
+{
+ private const string AccentedFolder = "Música épica";
+ private const string AccentedSong = "Canción.mp3";
+ private const string AsciiSong = "Song.mp3";
+
+ private readonly string _libraryRoot;
+ private readonly PlaylistItemsProvider _sut;
+
+ public PlaylistItemsProviderTests()
+ {
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+
+ _libraryRoot = Path.Combine(Path.GetTempPath(), "jellyfin-playlist-tests", Guid.NewGuid().ToString("N"));
+ var mediaFolder = Path.Combine(_libraryRoot, AccentedFolder);
+ Directory.CreateDirectory(mediaFolder);
+ File.WriteAllText(Path.Combine(mediaFolder, AccentedSong), string.Empty);
+ File.WriteAllText(Path.Combine(mediaFolder, AsciiSong), string.Empty);
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(m => m.FindByPath(It.IsAny<string>(), It.IsAny<bool?>()))
+ .Returns((string path, bool? _) => new Audio { Id = Guid.NewGuid(), Path = path });
+
+ var fileSystem = new Mock<IFileSystem>();
+ fileSystem.Setup(m => m.MakeAbsolutePath(It.IsAny<string>(), It.IsAny<string>()))
+ .Returns((string folderPath, string filePath) => Path.GetFullPath(Path.Combine(folderPath, filePath)));
+
+ _sut = new PlaylistItemsProvider(NullLogger<PlaylistItemsProvider>.Instance, libraryManager.Object, fileSystem.Object);
+ }
+
+ [Fact]
+ public void GetM3uItems_Utf8Entries_ResolvesAccentedPaths()
+ => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}"], 1);
+
+ /// <summary>
+ /// Playlists written by Windows media players default to the local codepage rather than UTF-8.
+ /// Decoding those as UTF-8 mangles every accented character and loses the entry.
+ /// </summary>
+ [Fact]
+ public void GetM3uItems_LegacyCodepageEntries_ResolvesAccentedPaths()
+ => AssertResolved(Encoding.GetEncoding(1252), [$"{AccentedFolder}/{AccentedSong}"], 1);
+
+ /// <summary>
+ /// The files on disk are stored precomposed (NFC), the playlist references them decomposed (NFD).
+ /// </summary>
+ [Fact]
+ public void GetM3uItems_DecomposedEntries_ResolvesAccentedPaths()
+ => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AccentedSong}".Normalize(NormalizationForm.FormD)], 1);
+
+ [Fact]
+ public void GetM3uItems_AsciiEntries_ResolvesPaths()
+ => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/{AsciiSong}"], 1);
+
+ [Fact]
+ public void GetM3uItems_Utf16Entries_ResolvesAccentedPaths()
+ => AssertResolved(Encoding.Unicode, [$"{AccentedFolder}/{AccentedSong}"], 1);
+
+ [Fact]
+ public void GetM3uItems_MixedEntries_ResolvesEveryEntry()
+ => AssertResolved(
+ Encoding.GetEncoding(1252),
+ [$"{AccentedFolder}/{AccentedSong}", $"{AccentedFolder}/{AsciiSong}"],
+ 2);
+
+ [Fact]
+ public void GetM3uItems_MissingFile_ResolvesNothing()
+ => AssertResolved(Encoding.UTF8, [$"{AccentedFolder}/Does not exist.mp3"], 0);
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_libraryRoot))
+ {
+ Directory.Delete(_libraryRoot, true);
+ }
+ }
+
+ private void AssertResolved(Encoding encoding, string[] entries, int expected)
+ {
+ var playlistPath = Path.Combine(_libraryRoot, "playlist.m3u");
+ var content = new StringBuilder("#EXTM3U\n");
+ foreach (var entry in entries)
+ {
+ content.Append("#EXTINF:1,Title\n").Append(entry).Append('\n');
+ }
+
+ File.WriteAllBytes(
+ playlistPath,
+ [.. encoding.GetPreamble(), .. encoding.GetBytes(content.ToString())]);
+
+ using var stream = File.OpenRead(playlistPath);
+ var resolved = _sut.GetM3uItems(stream, playlistPath, [_libraryRoot]).ToList();
+
+ Assert.Equal(expected, resolved.Count);
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
index 8f5b1b3c48..ea762256db 100644
--- a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
+++ b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
@@ -1,5 +1,6 @@
using System;
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
@@ -15,9 +16,23 @@ using Xunit;
namespace Jellyfin.Providers.Tests.TV;
-public class EpisodeMetadataServiceTests
+// put tests that mock the static LibraryManager in the same collection to avoid test interference
+[Collection("LibraryManagerTests")]
+public sealed class EpisodeMetadataServiceTests : IDisposable
{
private readonly TestEpisodeMetadataService _service = new();
+ private readonly ILibraryManager? _previousLibraryManager;
+
+ public EpisodeMetadataServiceTests()
+ {
+ _previousLibraryManager = BaseItem.LibraryManager;
+ BaseItem.LibraryManager = Mock.Of<ILibraryManager>();
+ }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager;
+ }
[Fact]
public void MergeData_ProviderSeasonOverridesPathDerivedSeason()
@@ -88,6 +103,59 @@ public class EpisodeMetadataServiceTests
Assert.Equal(1, target.Item.ParentIndexNumber);
}
+ [Theory]
+ [InlineData(2, 1)]
+ [InlineData(22, 21)]
+ [InlineData(21, 2)] // e.g. "Series - S03E21 - E2 (1080p BluRay x265).mkv", where "E2" is the episode title
+ public void BeforeSave_ReversedEpisodeRange_ClearsIndexNumberEnd(int indexNumber, int indexNumberEnd)
+ {
+ var item = new Episode
+ {
+ IndexNumber = indexNumber,
+ IndexNumberEnd = indexNumberEnd
+ };
+
+ var updateType = _service.BeforeSave(item);
+
+ // The episode number identifies the item, so it is kept and the impossible range is dropped
+ Assert.Equal(indexNumber, item.IndexNumber);
+ Assert.Null(item.IndexNumberEnd);
+ Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport));
+ }
+
+ [Fact]
+ public void BeforeSave_EpisodeRangeWithoutStart_ClearsIndexNumberEnd()
+ {
+ var item = new Episode
+ {
+ IndexNumber = null,
+ IndexNumberEnd = 2
+ };
+
+ var updateType = _service.BeforeSave(item);
+
+ Assert.Null(item.IndexNumberEnd);
+ Assert.Null(item.IndexNumber);
+ Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport));
+ }
+
+ [Theory]
+ [InlineData(1, 2)] // Regular multi episode file
+ [InlineData(1, 1)] // Degenerate but not contradictory
+ public void BeforeSave_ValidEpisodeRange_KeepsIndexNumberEnd(int indexNumber, int indexNumberEnd)
+ {
+ var item = new Episode
+ {
+ IndexNumber = indexNumber,
+ IndexNumberEnd = indexNumberEnd
+ };
+
+ _service.BeforeSave(item);
+
+ Assert.Equal(indexNumber, item.IndexNumber);
+ Assert.Equal(indexNumberEnd, item.IndexNumberEnd);
+ }
+
private sealed class TestEpisodeMetadataService : EpisodeMetadataService
{
public TestEpisodeMetadataService()
@@ -106,5 +174,10 @@ public class EpisodeMetadataServiceTests
{
MergeData(source, target, Array.Empty<MetadataField>(), replaceData, mergeMetadataSettings);
}
+
+ public ItemUpdateType BeforeSave(Episode item)
+ {
+ return BeforeSaveInternal(item, false, ItemUpdateType.None);
+ }
}
}
diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
index 4c4dd5e92f..03bad3555e 100644
--- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
@@ -1,6 +1,9 @@
+using System;
+using System.Collections.Generic;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Plugins.Tmdb;
+using TMDbLib.Objects.Search;
using Xunit;
namespace Jellyfin.Providers.Tests.Tmdb
@@ -71,5 +74,182 @@ namespace Jellyfin.Providers.Tests.Tmdb
Assert.False(new Movie().TryGetTmdbId(out var tmdbId));
Assert.Equal(0, tmdbId);
}
+
+ [Theory]
+ [InlineData("The Amityville Horror", "The Amityville Horror")]
+ [InlineData("WALL-E", "WALL E")]
+ // The interpunct is kept, it matches the TMDb title better than a space does.
+ [InlineData("WALL·E", "WALL·E")]
+ [InlineData("50-50", "50 50")]
+ [InlineData("A Christmas No. 1", "A Christmas No 1")]
+ // Vulgar fractions are numbers, dropping them turned "8½" into a search for "8".
+ [InlineData("8½", "8½")]
+ [InlineData("9½ Weeks", "9½ Weeks")]
+ [InlineData(" Léon: The Professional ", "Léon The Professional")]
+ public static void CleanName_Valid_Success(string name, string expected)
+ {
+ Assert.Equal(expected, TmdbUtils.CleanName(name));
+ }
+
+ [Theory]
+ [InlineData("WALL-E", "wall e")]
+ [InlineData("WALL·E", "wall e")]
+ [InlineData("WALL E", "wall e")]
+ [InlineData("8½", "8½")]
+ [InlineData("Ocean's Eleven", "ocean s eleven")]
+ [InlineData(null, "")]
+ [InlineData(" ", "")]
+ public static void NormalizeTitle_Valid_Success(string? title, string expected)
+ {
+ Assert.Equal(expected, TmdbUtils.NormalizeTitle(title));
+ }
+
+ [Theory]
+ // An unconfigured size fetches the original image, so it keeps the original resolution.
+ [InlineData(null, true)]
+ [InlineData("", true)]
+ [InlineData("original", true)]
+ [InlineData("Original", true)]
+ [InlineData("w500", false)]
+ [InlineData("original2", false)]
+ public static void IsOriginalImageSize_Valid_Success(string? size, bool expected)
+ {
+ Assert.Equal(expected, TmdbUtils.IsOriginalImageSize(size));
+ }
+
+ [Theory]
+ [MemberData(nameof(FindBestMatch_Movies_TestData))]
+ public static void FindBestMatch_Movies_PicksExpected(string description, string name, int year, IReadOnlyList<SearchMovie> results, int expectedId)
+ {
+ var match = TmdbUtils.FindBestMatch(results, name, year);
+
+ Assert.NotNull(match);
+ Assert.True(expectedId == match.Id, $"{description}: expected {expectedId} but matched {match.Id}");
+ }
+
+ [Fact]
+ public static void FindBestMatch_Series_PicksMatchingFirstAirYear()
+ {
+ IReadOnlyList<SearchTv> results =
+ [
+ Series(10042, "Doc", "Doc", 2001),
+ Series(101048, "Doc", "Doc", 2020),
+ Series(255055, "Doc", "Doc", 2025),
+ Series(2430, "Doc Martin", "Doc Martin", 2004)
+ ];
+
+ var match = TmdbUtils.FindBestMatch(results, "Doc", 2025);
+
+ Assert.NotNull(match);
+ Assert.Equal(255055, match.Id);
+ }
+
+ [Fact]
+ public static void FindBestMatch_NoResults_ReturnsNull()
+ {
+ Assert.Null(TmdbUtils.FindBestMatch(Array.Empty<SearchMovie>(), "Mulan", 2020));
+ Assert.Null(TmdbUtils.FindBestMatch(Array.Empty<SearchTv>(), "Doc", 2025));
+ Assert.Null(TmdbUtils.FindBestMatch((IReadOnlyList<SearchMovie>?)null, "Mulan", 2020));
+ Assert.Null(TmdbUtils.FindBestMatch((IReadOnlyList<SearchTv>?)null, "Doc", 2025));
+ }
+
+ public static TheoryData<string, string, int, IReadOnlyList<SearchMovie>, int> FindBestMatch_Movies_TestData()
+ => new()
+ {
+ // TMDb's year parameter does not filter, so the remake and the original both come back and
+ // the wrong one is first. Results are in the order the live API returned them.
+ {
+ "Mulan (2020)", "Mulan", 2020,
+ [Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020), Movie(752662, "Hua Mulan", "花木兰", 2020)],
+ 337401
+ },
+ {
+ "Mulan (1998)", "Mulan", 1998,
+ [Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020), Movie(752662, "Hua Mulan", "花木兰", 2020)],
+ 10674
+ },
+ {
+ "Aladdin (2019)", "Aladdin", 2019,
+ [Movie(812, "Aladdin", "Aladdin", 1992), Movie(420817, "Aladdin", "Aladdin", 2019), Movie(602411, "Adventures of Aladdin", "Adventures of Aladdin", 2019)],
+ 420817
+ },
+ {
+ "The Lion King (2019)", "The Lion King", 2019,
+ [Movie(8587, "The Lion King", "The Lion King", 1994), Movie(420818, "The Lion King", "The Lion King", 2019)],
+ 420818
+ },
+ {
+ "The Amityville Horror (1979)", "The Amityville Horror", 1979,
+ [Movie(10065, "The Amityville Horror", "The Amityville Horror", 2005), Movie(11449, "The Amityville Horror", "The Amityville Horror", 1979)],
+ 11449
+ },
+ // A featurette outranks the film it belongs to. The interpunct must not stop "WALL-E" from
+ // matching "WALL·E", or the prefix match on the featurette wins.
+ {
+ "WALL-E (2008)", "WALL-E", 2008,
+ [Movie(877268, "WALL·E's Treasures & Trinkets", "WALL·E's Treasures & Trinkets", 2008), Movie(10681, "WALL·E", "WALL·E", 2008), Movie(10673, "Wall Street", "Wall Street", 1987)],
+ 10681
+ },
+ // The name only survives as "8" if the fraction is stripped, and then every 1963 result ties.
+ {
+ "8½ (1963)", "8½", 1963,
+ [Movie(422801, "Interpol Code 8", "国際秘密警察 指令第8号", 1963), Movie(520251, "Um 8 Uhr kommt Sadowski", "Um 8 Uhr kommt Sadowski", 1963), Movie(422, "8½", "8½", 1963)],
+ 422
+ },
+ // Matched on the original title, the localized one is unrecognizable.
+ {
+ "Ściany mają uszy (1966)", "Ściany mają uszy", 1966,
+ [Movie(1, "Something Else", "Something Else", 1966), Movie(2, "Walls Have Ears", "Ściany mają uszy", 1966)],
+ 2
+ },
+ // Regional release dates straddle the new year, so a year that is off by one still matches.
+ {
+ "Off by one year", "Some Movie", 2011,
+ [Movie(1, "Some Movie", "Some Movie", 2015), Movie(2, "Some Movie", "Some Movie", 2010)],
+ 2
+ },
+ // Nothing matches the name, so TMDb's own ordering is kept.
+ {
+ "A Christmas No. 1 (2021)", "A Christmas No. 1", 2021,
+ [Movie(878111, "A Christmas Number One", "A Christmas Number One", 2021), Movie(2, "Ten Hours for Christmas", "10 Horas para o Natal", 2021)],
+ 878111
+ },
+ // A title that matches always beats one that only shares the year.
+ {
+ "Title outranks year", "Some Movie", 2020,
+ [Movie(1, "A Different Movie", "A Different Movie", 2020), Movie(2, "Some Movie", "Some Movie", 1994)],
+ 2
+ },
+ // Without a year the title alone decides, and equally good titles keep TMDb's order.
+ {
+ "No year known", "Mulan", 0,
+ [Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020)],
+ 10674
+ },
+ // An unparsable name must not throw or reorder anything.
+ {
+ "Empty name", " ", 2020,
+ [Movie(1, "Some Movie", "Some Movie", 1994), Movie(2, "Some Movie", "Some Movie", 2020)],
+ 1
+ }
+ };
+
+ private static SearchMovie Movie(int id, string title, string originalTitle, int year)
+ => new()
+ {
+ Id = id,
+ Title = title,
+ OriginalTitle = originalTitle,
+ ReleaseDate = new DateTime(year, 6, 1, 0, 0, 0, DateTimeKind.Utc)
+ };
+
+ private static SearchTv Series(int id, string name, string originalName, int year)
+ => new()
+ {
+ Id = id,
+ Name = name,
+ OriginalName = originalName,
+ FirstAirDate = new DateTime(year, 6, 1, 0, 0, 0, DateTimeKind.Utc)
+ };
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index 679e6d17e3..fd84cfb497 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -1,7 +1,11 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using Emby.Server.Implementations.Dto;
+using Jellyfin.Data;
+using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Common;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Drawing;
@@ -138,6 +142,96 @@ public class DtoServiceTests
Assert.Equal(9, dto.ChildCount);
}
+ [Fact]
+ public void GetBaseItemDtos_NoUser_SkipsTheChildCountBatch()
+ {
+ // A child count is attached only to a user's dto, so with no user the batch is work whose
+ // result nothing reads - and it is a grouped count over every item, not a cheap one.
+ var (season, _) = BuildSeason(playedCount: 0, totalCount: 0, childCount: 10);
+ var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] };
+
+ var dto = _dtoService.GetBaseItemDtos([season], options, user: null, skipVisibilityCheck: true)[0];
+
+ Assert.Null(dto.ChildCount);
+ _libraryManagerMock.Verify(
+ x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public void GetBaseItemDtos_GroupedMoviesView_CountsEveryLibraryGroupedIntoIt()
+ {
+ // The view has no library of its own, so its count is the sum over the libraries the user
+ // grouped into it - including an untyped one, which the view also shows.
+ var user = new User("user", "auth-provider", "reset-provider");
+ var grouped = BuildLibrary(CollectionType.movies);
+ var untyped = BuildLibrary(null);
+ var shows = BuildLibrary(CollectionType.tvshows);
+ var ungrouped = BuildLibrary(CollectionType.movies);
+ user.SetPreference(PreferenceKind.GroupedFolders, [grouped.Id, untyped.Id, shows.Id]);
+
+ // A real root folder would resolve its children through the library it does not have here.
+ var rootFolder = new Mock<Folder>();
+ rootFolder
+ .Setup(x => x.GetChildren(user, true, It.IsAny<InternalItemsQuery>()))
+ .Returns<User, bool, InternalItemsQuery>((_, _, _) => [grouped, untyped, shows, ungrouped]);
+ _libraryManagerMock.Setup(x => x.GetUserRootFolder()).Returns(rootFolder.Object);
+
+ IReadOnlyList<Guid>? counted = null;
+ _libraryManagerMock
+ .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()))
+ .Callback<IReadOnlyList<Guid>, User?>((ids, _) => counted = ids)
+ .Returns<IReadOnlyList<Guid>, User?>((ids, _) => ids.ToDictionary(id => id, _ => 4));
+
+ var view = new UserView { Id = Guid.NewGuid(), Name = "Movies", ViewType = CollectionType.movies };
+ var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] };
+
+ var dto = _dtoService.GetBaseItemDtos([view], options, user, skipVisibilityCheck: true)[0];
+
+ Assert.Equal(grouped.PhysicalFolderIds.Concat(untyped.PhysicalFolderIds), counted);
+ Assert.Equal(16, dto.ChildCount);
+ }
+
+ [Fact]
+ public void GetBaseItemDtos_SubViewOfALibrary_DoesNotCountTheLibrary()
+ {
+ // A sub-view hangs off the library the view was built over, but it holds a query over it,
+ // not its children: counting the library would report every movie as "Continue Watching".
+ var user = new User("user", "auth-provider", "reset-provider");
+ var library = BuildLibrary(CollectionType.movies);
+ _libraryManagerMock.Setup(x => x.GetItemById(library.Id)).Returns(library);
+
+ // The fallback count a sub-view falls through to runs a query of its own.
+ _libraryManagerMock
+ .Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns([]);
+
+ var subView = new UserView
+ {
+ Id = Guid.NewGuid(),
+ Name = "Continue Watching",
+ ViewType = CollectionType.movieresume,
+ DisplayParentId = library.Id
+ };
+ var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount] };
+
+ _dtoService.GetBaseItemDtos([subView], options, user, skipVisibilityCheck: true);
+
+ _libraryManagerMock.Verify(
+ x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()),
+ Times.Never);
+ }
+
+ private static CollectionFolder BuildLibrary(CollectionType? collectionType)
+ {
+ return new CollectionFolder
+ {
+ Id = Guid.NewGuid(),
+ CollectionType = collectionType,
+ PhysicalFolderIds = [Guid.NewGuid(), Guid.NewGuid()]
+ };
+ }
+
private (Season Season, User User) BuildSeason(int playedCount, int totalCount, int childCount)
{
var user = new User("user", "auth-provider", "reset-provider");
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs
new file mode 100644
index 0000000000..54fec0a0d3
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Entities/UserViewBuilderTests.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Entities;
+
+public sealed class UserViewBuilderTests
+{
+ private static readonly User _user = new("view-filter-test", "provider", "reset");
+
+ [Fact]
+ public void Filter_IsPlayed_CountsAMovieWatchedOnAnAlternateVersionAsPlayed()
+ {
+ // The primary carries no played row of its own; the version that was watched is another file.
+ var onlyWatchedOnAlternate = new Movie { Id = Guid.NewGuid(), Name = "Watched as a second cut" };
+ var watched = new Movie { Id = Guid.NewGuid(), Name = "Watched outright" };
+ var unwatched = new Movie { Id = Guid.NewGuid(), Name = "Not watched" };
+
+ var items = new BaseItem[] { onlyWatchedOnAlternate, watched, unwatched };
+
+ var userDataManager = new Mock<IUserDataManager>();
+ userDataManager
+ .Setup(m => m.GetUserData(_user, It.IsAny<BaseItem>()))
+ .Returns((User _, BaseItem item) => new UserItemData { Key = item.Id.ToString("N"), Played = item.Id.Equals(watched.Id) });
+ userDataManager
+ .Setup(m => m.GetResumeUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), _user))
+ .Returns(new Dictionary<Guid, VersionResumeData>
+ {
+ [onlyWatchedOnAlternate.Id] = new(Guid.NewGuid(), new UserItemData { Key = "alternate", Played = true })
+ });
+
+ var libraryManager = new Mock<ILibraryManager>();
+
+ var played = UserViewBuilder.Filter(
+ items,
+ _user,
+ new InternalItemsQuery(_user) { IsPlayed = true },
+ userDataManager.Object,
+ libraryManager.Object).ToList();
+
+ var unplayed = UserViewBuilder.Filter(
+ items,
+ _user,
+ new InternalItemsQuery(_user) { IsPlayed = false },
+ userDataManager.Object,
+ libraryManager.Object).ToList();
+
+ // The alternate's playback settles the movie, exactly as the item's own dto reports it.
+ Assert.Equal([onlyWatchedOnAlternate.Id, watched.Id], played.Select(i => i.Id));
+ Assert.Equal([unwatched.Id], unplayed.Select(i => i.Id));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs
new file mode 100644
index 0000000000..cdb261de8d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.EntryPoints;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
+
+public class LibraryChangedNotifierTests
+{
+ // How long a test waits for the notifier's timer callback to run. Generous: the assertions are
+ // about a batch being sent at all, not about how promptly.
+ private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
+
+ private readonly Mock<ILibraryManager> _libraryManager = new();
+ private readonly Mock<IServerConfigurationManager> _configurationManager = new();
+ private readonly Mock<ISessionManager> _sessionManager = new();
+ private readonly Mock<IUserManager> _userManager = new();
+ private readonly Mock<IProviderManager> _providerManager = new();
+ private readonly ServerConfiguration _configuration = new();
+
+ private int _flushCount;
+
+ public LibraryChangedNotifierTests()
+ {
+ _configurationManager.SetupGet(e => e.Configuration).Returns(_configuration);
+
+ // Reading the session list is the first thing a flush does, so it stands in for "a batch was
+ // sent" without having to mock a whole user library behind it.
+ _sessionManager.SetupGet(e => e.Sessions)
+ .Returns(() =>
+ {
+ Interlocked.Increment(ref _flushCount);
+ return [];
+ });
+ }
+
+ [Fact]
+ public async Task OnLibraryItemUpdated_BatchSizeCapReached_SendsWithoutWaitingForWindow()
+ {
+ // Long enough that only the size cap can close the batch.
+ _configuration.LibraryUpdateDuration = 3600;
+
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ for (var i = 0; i < LibraryChangedNotifier.MaxBatchSize; i++)
+ {
+ RaiseItemUpdated();
+ }
+
+ Assert.True(await WaitForFlushAsync(1), "The batch was not sent once it hit the size cap.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ [Fact]
+ public async Task OnLibraryItemUpdated_ChangesNeverPause_StillSendsOnTheWindow()
+ {
+ // A scan changes items continuously. The window must run from the first change of a batch, or
+ // the batch never closes and holds every item it named alive for the length of the scan.
+ _configuration.LibraryUpdateDuration = 1;
+
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
+ {
+ // Well below the window, and well below the size cap over the whole loop.
+ RaiseItemUpdated();
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ private LibraryChangedNotifier CreateNotifier()
+ => new(
+ _libraryManager.Object,
+ _configurationManager.Object,
+ _sessionManager.Object,
+ _userManager.Object,
+ NullLogger<LibraryChangedNotifier>.Instance,
+ _providerManager.Object);
+
+ // A folder passes the notifier's item filter without needing any of BaseItem's static services.
+ private void RaiseItemUpdated()
+ => _libraryManager.Raise(
+ e => e.ItemUpdated += null,
+ _libraryManager.Object,
+ new ItemChangeEventArgs { Item = new Folder { Id = Guid.NewGuid() } });
+
+ private async Task<bool> WaitForFlushAsync(int expected)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout)
+ {
+ if (Volatile.Read(ref _flushCount) >= expected)
+ {
+ return true;
+ }
+
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ return false;
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs
new file mode 100644
index 0000000000..0274398f89
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.EntryPoints;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Session;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
+
+public class UserDataChangeNotifierTests
+{
+ // How long a test waits for the notifier's timer callback to run. Generous: the assertions are
+ // about a batch being sent at all, not about how promptly.
+ private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
+
+ private readonly Mock<IUserDataManager> _userDataManager = new();
+ private readonly Mock<ISessionManager> _sessionManager = new();
+ private readonly Mock<IUserManager> _userManager = new();
+
+ private int _flushCount;
+
+ public UserDataChangeNotifierTests()
+ {
+ _sessionManager
+ .Setup(e => e.SendMessageToUserSessions(
+ It.IsAny<System.Collections.Generic.List<Guid>>(),
+ SessionMessageType.UserDataChanged,
+ It.IsAny<Func<UserDataChangeInfo>>(),
+ It.IsAny<CancellationToken>()))
+ .Callback(() => Interlocked.Increment(ref _flushCount))
+ .Returns(Task.CompletedTask);
+ }
+
+ [Fact]
+ public async Task OnUserDataSaved_ChangesNeverPause_StillSendsOnTheWindow()
+ {
+ // A scan changes user data continuously. The window must run from the first change of a batch,
+ // or the batch never closes and holds every item it named alive for the length of the scan.
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ var userId = Guid.NewGuid();
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
+ {
+ // Well below the window, and well below the size cap over the whole loop.
+ RaiseUserDataSaved(userId);
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ private UserDataChangeNotifier CreateNotifier()
+ => new(_userDataManager.Object, _sessionManager.Object, _userManager.Object);
+
+ // A folder needs none of BaseItem's static services, and PlaybackProgress is the one reason the
+ // notifier ignores outright.
+ private void RaiseUserDataSaved(Guid userId)
+ => _userDataManager.Raise(
+ e => e.UserDataSaved += null,
+ _userDataManager.Object,
+ new UserDataSaveEventArgs
+ {
+ UserId = userId,
+ SaveReason = UserDataSaveReason.UpdateUserRating,
+ Item = new Folder { Id = Guid.NewGuid() }
+ });
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs
index 22667ee82d..b9ae16255e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs
@@ -1,7 +1,11 @@
using System;
using System.Buffers;
using System.IO;
+using System.Net.WebSockets;
+using System.Text;
using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
using Emby.Server.Implementations.HttpServer;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
@@ -48,6 +52,92 @@ namespace Jellyfin.Server.Implementations.Tests.HttpServer
Assert.Throws<JsonException>(() => con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed));
}
+ [Fact]
+ public async Task ReceiveAsync_SocketTornDownWhileAnswering_RaisesClosedWithoutThrowing()
+ {
+ // The keep-alive watchdog can dispose a connection while the receive loop is
+ // answering a message on it. The failing answer must not escape into the request
+ // handler, as that would skip the Closed event the session needs to release it.
+ var socket = new DisposedOnSendWebSocket(Encoding.UTF8.GetBytes("{\"MessageType\":\"KeepAlive\"}"));
+ var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), socket, null!, null!)
+ {
+ OnReceive = _ => Task.CompletedTask
+ };
+
+ var closed = false;
+ con.Closed += (_, _) => closed = true;
+
+ await con.ReceiveAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(closed);
+ Assert.Equal(1, socket.SendAttempts);
+ }
+
+ /// <summary>
+ /// A socket that hands out a single message and then behaves like a socket that was
+ /// disposed underneath the receive loop.
+ /// </summary>
+ internal sealed class DisposedOnSendWebSocket : WebSocket
+ {
+ private readonly byte[] _message;
+ private bool _received;
+
+ public DisposedOnSendWebSocket(byte[] message)
+ {
+ _message = message;
+ }
+
+ public int SendAttempts { get; private set; }
+
+ public override WebSocketCloseStatus? CloseStatus => null;
+
+ public override string? CloseStatusDescription => null;
+
+ public override string? SubProtocol => null;
+
+ public override WebSocketState State => SendAttempts == 0 ? WebSocketState.Open : WebSocketState.Closed;
+
+ public override void Abort()
+ {
+ }
+
+ public override Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public override Task CloseOutputAsync(WebSocketCloseStatus closeStatus, string? statusDescription, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+
+ public override void Dispose()
+ {
+ }
+
+ public override ValueTask<ValueWebSocketReceiveResult> ReceiveAsync(Memory<byte> buffer, CancellationToken cancellationToken)
+ {
+ ObjectDisposedException.ThrowIf(_received, this);
+
+ _received = true;
+ _message.CopyTo(buffer);
+ return ValueTask.FromResult(new ValueWebSocketReceiveResult(_message.Length, WebSocketMessageType.Text, true));
+ }
+
+ public override Task<WebSocketReceiveResult> ReceiveAsync(ArraySegment<byte> buffer, CancellationToken cancellationToken)
+ => throw new NotImplementedException();
+
+ public override ValueTask SendAsync(ReadOnlyMemory<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken)
+ => throw FailSend();
+
+ public override Task SendAsync(ArraySegment<byte> buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken)
+ => throw FailSend();
+
+ private WebSocketException FailSend()
+ {
+ SendAttempts++;
+ return new WebSocketException(
+ WebSocketError.InvalidState,
+ "The WebSocket is in an invalid state ('Closed') for this operation. Valid states are: 'Open, CloseReceived'");
+ }
+ }
+
internal sealed class BufferSegment : ReadOnlySequenceSegment<byte>
{
public BufferSegment(Memory<byte> memory)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs
new file mode 100644
index 0000000000..fd5f8e4160
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/IO/FileRefresherTests.cs
@@ -0,0 +1,57 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.IO;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.IO;
+
+public class FileRefresherTests
+{
+ [Fact]
+ public async Task ProcessPathChanges_PathLookupThrows_StillRefreshesRemainingPaths()
+ {
+ var tempDir = Directory.CreateTempSubdirectory("filerefresher");
+ try
+ {
+ // Ordered so the failing path is dequeued first.
+ var failingPath = Path.Combine(tempDir.FullName, "failing", "episode.mkv");
+ var workingPath = Directory.CreateDirectory(Path.Combine(tempDir.FullName, "working")).FullName;
+
+ var workingItem = new Folder { Path = workingPath, Name = "working" };
+ var workingItemFound = new TaskCompletionSource();
+
+ var libraryManager = new Mock<ILibraryManager>(MockBehavior.Loose);
+ libraryManager.Setup(x => x.FindByPath(failingPath, null))
+ .Throws(new ObjectDisposedException("IServiceProvider"));
+ libraryManager.Setup(x => x.FindByPath(workingPath, null))
+ .Returns(workingItem)
+ .Callback(() => workingItemFound.TrySetResult());
+
+ var configurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Loose);
+ configurationManager.Setup(x => x.Configuration)
+ .Returns(new ServerConfiguration { LibraryMonitorDelay = 1 });
+
+ using var refresher = new FileRefresher(
+ failingPath,
+ configurationManager.Object,
+ libraryManager.Object,
+ NullLogger.Instance);
+ refresher.AddPath(workingPath);
+
+ await workingItemFound.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
+
+ libraryManager.Verify(x => x.FindByPath(failingPath, null), Times.Once);
+ }
+ finally
+ {
+ tempDir.Delete(true);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
index 6cadfacce8..b39ca83483 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
@@ -100,6 +100,41 @@ public partial class ManagedFileSystemTests
Assert.Equal(expectedFileName, _sut.GetValidFilename(filename));
}
+ [Theory]
+ [InlineData("/media", "/media/tv", true)]
+ [InlineData("/media", "/media/tv/show/episode.mkv", true)]
+ [InlineData("/media/", "/media/tv", true)]
+ [InlineData("/", "/media", true)]
+ [InlineData("/media", "/media", false)]
+ [InlineData("/media", "/media/", true)]
+ [InlineData("/media", "/data/media/tv", false)]
+ [InlineData("/media", "/mediastuff/tv", false)]
+ [InlineData("/data/media", "/data/media/tv", true)]
+ [InlineData("/media/tv", "/media", false)]
+ [InlineData("/MEDIA", "/media/tv", false)]
+ public void ContainsSubPath_Unix_ReturnsExpected(string parentPath, string path, bool expected)
+ {
+ Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix-only test");
+
+ Assert.Equal(expected, _sut.ContainsSubPath(parentPath, path));
+ }
+
+ [Theory]
+ [InlineData(@"C:\media", @"C:\media\tv", true)]
+ [InlineData(@"C:\media\", @"C:\media\tv", true)]
+ [InlineData(@"C:\", @"C:\media", true)]
+ [InlineData(@"C:\media", @"C:\media", false)]
+ [InlineData(@"C:\media", @"C:\data\media\tv", false)]
+ [InlineData(@"C:\media", @"C:\mediastuff\tv", false)]
+ [InlineData(@"C:\MEDIA", @"C:\media\tv", true)]
+ [InlineData(@"C:\media", @"C:\media/tv", true)]
+ public void ContainsSubPath_Windows_ReturnsExpected(string parentPath, string path, bool expected)
+ {
+ Assert.SkipUnless(OperatingSystem.IsWindows(), "Windows-only test");
+
+ Assert.Equal(expected, _sut.ContainsSubPath(parentPath, path));
+ }
+
[Fact]
public void GetFileInfo_DanglingSymlink_ExistsFalse()
{
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs
new file mode 100644
index 0000000000..abe1e59496
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemMapperUserDataTests.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the user data rows <see cref="BaseItemMapper"/> hands the domain item. A domain item is
+/// held for as long as its folder holds it, so a row that still points back at the entity it was
+/// read with would keep that entity - and everything loaded alongside it - alive with it.
+/// </summary>
+public class BaseItemMapperUserDataTests
+{
+ [Fact]
+ public void Map_CopiesUserDataWithoutTheEntityGraphBehindIt()
+ {
+ var itemId = Guid.NewGuid();
+ var userId = Guid.NewGuid();
+ var user = new User("someone", "Default", "Default");
+ var entity = new BaseItemEntity { Id = itemId, Type = "MediaBrowser.Controller.Entities.TV.Episode" };
+
+ var row = new UserData
+ {
+ ItemId = itemId,
+ Item = entity,
+ UserId = userId,
+ User = user,
+ CustomDataKey = "key",
+ PlayCount = 3,
+ PlaybackPositionTicks = 1234,
+ IsFavorite = true,
+ Played = true,
+ Rating = 7.5,
+ LastPlayedDate = new DateTime(2026, 9, 8, 0, 0, 0, DateTimeKind.Utc),
+ AudioStreamIndex = 1,
+ SubtitleStreamIndex = 2,
+ Likes = true
+ };
+
+ entity.UserData = [row];
+
+ var dto = BaseItemMapper.Map(entity, new Folder(), null);
+
+ var mapped = Assert.Single(dto.UserData);
+ Assert.Null(mapped.Item);
+ Assert.Null(mapped.User);
+
+ // The values callers actually read still come through.
+ Assert.Equal(itemId, mapped.ItemId);
+ Assert.Equal(userId, mapped.UserId);
+ Assert.Equal("key", mapped.CustomDataKey);
+ Assert.Equal(3, mapped.PlayCount);
+ Assert.Equal(1234, mapped.PlaybackPositionTicks);
+ Assert.True(mapped.IsFavorite);
+ Assert.True(mapped.Played);
+ Assert.Equal(7.5, mapped.Rating);
+ Assert.Equal(row.LastPlayedDate, mapped.LastPlayedDate);
+ Assert.Equal(1, mapped.AudioStreamIndex);
+ Assert.Equal(2, mapped.SubtitleStreamIndex);
+ Assert.True(mapped.Likes);
+ }
+
+ [Fact]
+ public void Map_WithoutUserData_YieldsAnEmptyCollection()
+ {
+ var entity = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = "MediaBrowser.Controller.Entities.Folder"
+ };
+
+ var dto = BaseItemMapper.Map(entity, new Folder(), null);
+
+ Assert.Empty(dto.UserData);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs
new file mode 100644
index 0000000000..298340d1b0
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameItemCountsTests.cs
@@ -0,0 +1,215 @@
+using System;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Querying;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// The by-name listings count what a cleaned value is attached to by joining ItemValuesMap to
+/// BaseItems. One item can reach the same clean value through more than one value row, so the
+/// join has to be counted per distinct item; counting rows reports a multiple of the truth.
+/// </summary>
+public sealed class BaseItemRepositoryByNameItemCountsTests : SqliteDbTestFixture
+{
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryByNameItemCountsTests()
+ {
+ _itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(_itemTypeLookup);
+ }
+
+ [Fact]
+ public void GetAllArtists_AlbumCreditedAsArtistAndAlbumArtist_CountsTheAlbumOnce()
+ {
+ // GetAllArtists spans both credit types, so an album whose artist is also its album artist
+ // reaches the one clean value through two rows.
+ SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.AlbumArtist);
+
+ var result = _repository.GetAllArtists(CreateCountingQuery());
+
+ var (_, counts) = Assert.Single(result.Items);
+ Assert.NotNull(counts);
+ Assert.Equal(1, counts.AlbumCount);
+ Assert.Equal(1, counts.ItemCount);
+ }
+
+ [Fact]
+ public void GetAlbumArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce()
+ {
+ // The shape that actually reaches users: only (Type, Value) is unique, so two differently
+ // cased credits of one type both clean down to a single name and both map the album.
+ SeedArtistWithAlbum(ItemValueType.AlbumArtist, ItemValueType.AlbumArtist);
+
+ var result = _repository.GetAlbumArtists(CreateCountingQuery());
+
+ var (_, counts) = Assert.Single(result.Items);
+ Assert.NotNull(counts);
+ Assert.Equal(1, counts.AlbumCount);
+ }
+
+ [Fact]
+ public void GetArtists_TwoValueRowsCleaningToOneName_CountsTheAlbumOnce()
+ {
+ SeedArtistWithAlbum(ItemValueType.Artist, ItemValueType.Artist);
+
+ var result = _repository.GetArtists(CreateCountingQuery());
+
+ var (_, counts) = Assert.Single(result.Items);
+ Assert.NotNull(counts);
+ Assert.Equal(1, counts.AlbumCount);
+ }
+
+ [Theory]
+ [InlineData(BaseItemKind.Book)]
+ [InlineData(BaseItemKind.BoxSet)]
+ public void GetGenres_TaggedBookOrBoxSet_CountsIt(BaseItemKind kind)
+ {
+ // The listing used to dispatch only nine of the eleven counted types, so a genre on a book
+ // or a box set read as zero in a list and as one on the genre's own page.
+ SeedGenreWith(kind);
+
+ var result = _repository.GetGenres(CreateCountingQuery());
+
+ var (_, counts) = Assert.Single(result.Items);
+ Assert.NotNull(counts);
+ Assert.Equal(1, kind == BaseItemKind.Book ? counts.BookCount : counts.BoxSetCount);
+ Assert.Equal(1, counts.ItemCount);
+ }
+
+ /// <summary>
+ /// Seeds one genre carried by a single item of the given kind.
+ /// </summary>
+ /// <param name="kind">The kind of the tagged item.</param>
+ private void SeedGenreWith(BaseItemKind kind)
+ {
+ const string Name = "Reference";
+ const string CleanName = "reference";
+
+ using var ctx = CreateDbContext();
+
+ var genreId = Guid.Parse("dddddddd-0000-0000-0000-000000000001");
+ var taggedId = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001");
+
+ ctx.BaseItems.Add(new BaseItemEntity
+ {
+ Id = genreId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre],
+ Name = Name,
+ CleanName = CleanName,
+ PresentationUniqueKey = genreId.ToString("N"),
+ IsFolder = true,
+ IsVirtualItem = false
+ });
+
+ var tagged = new BaseItemEntity
+ {
+ Id = taggedId,
+ Type = _itemTypeLookup.BaseItemKindNames[kind],
+ Name = "Tagged",
+ CleanName = "tagged",
+ PresentationUniqueKey = taggedId.ToString("N"),
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ ctx.BaseItems.Add(tagged);
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.Parse("ffffffff-0000-0000-0000-000000000001"),
+ Type = ItemValueType.Genre,
+ Value = Name,
+ CleanValue = CleanName
+ };
+
+ ctx.ItemValues.Add(itemValue);
+ ctx.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = taggedId,
+ ItemValueId = itemValue.ItemValueId,
+ Item = tagged,
+ ItemValue = itemValue
+ });
+
+ ctx.SaveChanges();
+ }
+
+ private static InternalItemsQuery CreateCountingQuery()
+ {
+ return new InternalItemsQuery(new User("test", "auth", "reset"))
+ {
+ DtoOptions = new DtoOptions(true) { Fields = [ItemFields.ItemCounts] }
+ };
+ }
+
+ /// <summary>
+ /// Seeds one artist and a single album mapped to that artist's clean name through two value
+ /// rows of the given types.
+ /// </summary>
+ /// <param name="first">The type of the first value row.</param>
+ /// <param name="second">The type of the second value row.</param>
+ private void SeedArtistWithAlbum(ItemValueType first, ItemValueType second)
+ {
+ const string Name = "Tangerine Dream";
+ const string CleanName = "tangerine dream";
+
+ using var ctx = CreateDbContext();
+
+ var artistId = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
+ var albumId = Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001");
+
+ ctx.BaseItems.Add(new BaseItemEntity
+ {
+ Id = artistId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist],
+ Name = Name,
+ CleanName = CleanName,
+ PresentationUniqueKey = artistId.ToString("N"),
+ IsFolder = true,
+ IsVirtualItem = false
+ });
+
+ var album = new BaseItemEntity
+ {
+ Id = albumId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum],
+ Name = "Phaedra",
+ CleanName = "phaedra",
+ PresentationUniqueKey = albumId.ToString("N"),
+ IsFolder = true,
+ IsVirtualItem = false
+ };
+ ctx.BaseItems.Add(album);
+
+ var types = new[] { first, second };
+ for (var i = 0; i < types.Length; i++)
+ {
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}"),
+ Type = types[i],
+ // Distinct values, one clean name: exactly what the unique index permits.
+ Value = i == 0 ? Name : Name.ToUpperInvariant(),
+ CleanValue = CleanName
+ };
+
+ ctx.ItemValues.Add(itemValue);
+ ctx.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = albumId,
+ ItemValueId = itemValue.ItemValueId,
+ Item = album,
+ ItemValue = itemValue
+ });
+ }
+
+ ctx.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs
new file mode 100644
index 0000000000..5e045e9f83
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryChildrenTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the children query the library scan runs against a folder: a version merged by hand is
+/// hidden from ordinary queries, but the scan has to see it or it takes the row for a new item and
+/// recreates it, splitting the version group apart again.
+/// </summary>
+public sealed class BaseItemRepositoryChildrenTests : SqliteDbTestFixture
+{
+ private static readonly Guid _folderId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _primaryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ private static readonly Guid _mergedVersionId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ private static readonly Guid _ownedVersionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ private readonly BaseItemRepository _repository;
+
+ public BaseItemRepositoryChildrenTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _repository = CreateBaseItemRepository(itemTypeLookup);
+
+ var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ using var ctx = CreateDbContext();
+ ctx.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _folderId,
+ Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!,
+ Name = "Movies",
+ Path = "/movies",
+ IsFolder = true
+ });
+ ctx.BaseItems.Add(CreateMovie(_primaryId, movieTypeName!, "Big Buck Bunny", "/media1/Big Buck Bunny/bbb-1080p.mp4", null, null));
+ ctx.BaseItems.Add(CreateMovie(_mergedVersionId, movieTypeName!, "Big Buck Bunny", "/media2/Big Buck Bunny/bbb-2160p.mp4", _primaryId, null));
+ ctx.BaseItems.Add(CreateMovie(_ownedVersionId, movieTypeName!, "Big Buck Bunny - 720p", "/media1/Big Buck Bunny/bbb-720p.mp4", _primaryId, _primaryId));
+ ctx.SaveChanges();
+ }
+
+ [Fact]
+ public void GetItemList_ChildrenOfFolder_ExcludesAlternateVersionsByDefault()
+ {
+ var result = _repository.GetItemList(new InternalItemsQuery { ParentId = _folderId });
+
+ var item = Assert.Single(result);
+ Assert.Equal(_primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_KeepsMergedVersion()
+ {
+ var result = _repository.GetItemList(new InternalItemsQuery
+ {
+ ParentId = _folderId,
+ IncludeAlternateVersions = true
+ });
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, i => i.Id.Equals(_primaryId));
+ Assert.Contains(result, i => i.Id.Equals(_mergedVersionId));
+ }
+
+ [Fact]
+ public void GetItemList_ChildrenOfFolderIncludingAlternateVersions_StillExcludesOwnedVersion()
+ {
+ // A version stored next to the file it belongs to is owned by its primary and is never
+ // resolved on its own, so the scan must not see it as a child of the folder either.
+ var result = _repository.GetItemList(new InternalItemsQuery
+ {
+ ParentId = _folderId,
+ IncludeAlternateVersions = true
+ });
+
+ Assert.DoesNotContain(result, i => i.Id.Equals(_ownedVersionId));
+ }
+
+ private static BaseItemEntity CreateMovie(Guid id, string typeName, string name, string path, Guid? primaryVersionId, Guid? ownerId)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = typeName,
+ Name = name,
+ Path = path,
+ ParentId = _folderId,
+ TopParentId = _folderId,
+ PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"),
+ PrimaryVersionId = primaryVersionId,
+ OwnerId = ownerId,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
index 535961a66c..9238ec9fd1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs
@@ -13,13 +13,18 @@ namespace Jellyfin.Server.Implementations.Tests.Item;
public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
{
+ private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
private readonly BaseItemRepository _repository;
private readonly string _movieTypeName;
+ private readonly string _folderTypeName;
public BaseItemRepositoryGroupingTests()
{
var itemTypeLookup = new ItemTypeLookup();
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder];
_repository = CreateBaseItemRepository(itemTypeLookup);
}
@@ -67,6 +72,118 @@ public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
Assert.Equal(firstId, item.Id);
}
+ [Fact]
+ public void GetItemList_LibraryWithoutThePrimaryOfTheGroup_KeepsTheVersionVisible()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+ var sameLibraryPrimaryId = Guid.Parse("55555555-5555-5555-5555-555555555555");
+ var sameLibraryVersionId = Guid.Parse("66666666-6666-6666-6666-666666666666");
+
+ SeedCrossLibraryGroup(primaryId, versionId, sameLibraryPrimaryId, sameLibraryVersionId);
+
+ var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId));
+
+ // The version stands in for the group in the library it lives in, because its primary is in
+ // a library of its own; a group merged inside this library still collapses onto its primary.
+ Assert.Contains(result, i => i.Id.Equals(versionId));
+ Assert.Contains(result, i => i.Id.Equals(sameLibraryPrimaryId));
+ Assert.DoesNotContain(result, i => i.Id.Equals(sameLibraryVersionId));
+ Assert.DoesNotContain(result, i => i.Id.Equals(primaryId));
+ }
+
+ [Fact]
+ public void GetItemList_LibraryHoldingThePrimary_ReturnsThePrimary()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ SeedCrossLibraryGroup(primaryId, versionId);
+
+ var result = _repository.GetItemList(CreateLibraryQuery(_movie4KLibraryId));
+
+ var item = Assert.Single(result);
+ Assert.Equal(primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItemList_BothLibrariesOfACrossLibraryGroup_ReturnsItOnce()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ SeedCrossLibraryGroup(primaryId, versionId);
+
+ var result = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId, _movie4KLibraryId));
+
+ // With both libraries in scope the presentation key grouping collapses the version.
+ var item = Assert.Single(result);
+ Assert.Equal(primaryId, item.Id);
+ }
+
+ [Fact]
+ public void GetItems_LibraryWithoutThePrimaryOfTheGroup_CountsWhatItLists()
+ {
+ var primaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var versionId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ SeedCrossLibraryGroup(primaryId, versionId);
+
+ var listed = _repository.GetItemList(CreateLibraryQuery(_movieLibraryId)).Count;
+
+ var query = CreateLibraryQuery(_movieLibraryId);
+ query.EnableTotalRecordCount = true;
+ query.Limit = 1;
+
+ // The total the client pages against has to agree with the listing.
+ Assert.Equal(1, listed);
+ Assert.Equal(listed, _repository.GetItems(query).TotalRecordCount);
+ }
+
+ private static InternalItemsQuery CreateLibraryQuery(params Guid[] topParentIds)
+ {
+ return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie],
+ TopParentIds = topParentIds
+ };
+ }
+
+ private void SeedCrossLibraryGroup(
+ Guid primaryId,
+ Guid versionId,
+ Guid? sameLibraryPrimaryId = null,
+ Guid? sameLibraryVersionId = null)
+ {
+ using var ctx = CreateDbContext();
+ ctx.BaseItems.Add(CreateFolderEntity(_movieLibraryId, "Movies"));
+ ctx.BaseItems.Add(CreateFolderEntity(_movie4KLibraryId, "Movies-4K"));
+
+ // The 4K version heads the group and lives in a library of its own.
+ ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie - 4K", primaryId.ToString("N"), null, _movie4KLibraryId));
+ ctx.BaseItems.Add(CreateMovieEntity(versionId, "Movie", primaryId.ToString("N"), primaryId, _movieLibraryId));
+
+ if (sameLibraryPrimaryId.HasValue && sameLibraryVersionId.HasValue)
+ {
+ ctx.BaseItems.Add(CreateMovieEntity(sameLibraryPrimaryId.Value, "Other - 4K", sameLibraryPrimaryId.Value.ToString("N"), null, _movieLibraryId));
+ ctx.BaseItems.Add(CreateMovieEntity(sameLibraryVersionId.Value, "Other", sameLibraryPrimaryId.Value.ToString("N"), sameLibraryPrimaryId.Value, _movieLibraryId));
+ }
+
+ ctx.SaveChanges();
+ }
+
+ private BaseItemEntity CreateFolderEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _folderTypeName,
+ Name = name,
+ Path = "/" + name,
+ IsFolder = true
+ };
+ }
+
private static InternalItemsQuery CreateQuery()
{
// IncludeOwnedItems keeps the alternate version rows in the query so the
@@ -78,13 +195,15 @@ public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture
};
}
- private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId)
+ private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId, Guid? libraryId = null)
{
return new BaseItemEntity
{
Id = id,
Type = _movieTypeName,
Name = name,
+ ParentId = libraryId,
+ TopParentId = libraryId,
PresentationUniqueKey = presentationKey,
PrimaryVersionId = primaryVersionId,
MediaType = "Video",
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
new file mode 100644
index 0000000000..039693c432
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
@@ -0,0 +1,218 @@
+using System;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture
+{
+ private readonly BaseItemRepository _repository;
+ private readonly string _audioTypeName;
+ private readonly string _movieTypeName;
+
+ public BaseItemRepositoryItemValueTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _audioTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
+ _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ _repository = CreateBaseItemRepository(itemTypeLookup);
+ }
+
+ [Fact]
+ public void GetQueryFiltersLegacy_GroupsAndFiltersItemValues()
+ {
+ var firstItem = CreateMovieEntity(Guid.NewGuid(), "First");
+ var secondItem = CreateMovieEntity(Guid.NewGuid(), "Second");
+ var excludedItem = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Excluded Audio",
+ MediaType = "Audio",
+ IsMovie = false,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var firstTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Alpha",
+ CleanValue = "alpha"
+ };
+ var duplicateTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "alpha",
+ CleanValue = "alpha"
+ };
+ var secondTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Beta",
+ CleanValue = "beta"
+ };
+ var genre = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = "Genre Leak",
+ CleanValue = "genre leak"
+ };
+ var excludedTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Excluded Tag",
+ CleanValue = "excluded tag"
+ };
+ var excludedGenre = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = "Excluded Genre",
+ CleanValue = "excluded genre"
+ };
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(firstItem, secondItem, excludedItem);
+ context.ItemValues.AddRange(firstTag, duplicateTag, secondTag, genre, excludedTag, excludedGenre);
+ context.ItemValuesMap.AddRange(
+ CreateMap(firstItem, firstTag),
+ CreateMap(firstItem, duplicateTag),
+ CreateMap(secondItem, secondTag),
+ CreateMap(firstItem, genre),
+ CreateMap(excludedItem, excludedTag),
+ CreateMap(excludedItem, excludedGenre));
+ context.SaveChanges();
+ }
+
+ var result = _repository.GetQueryFiltersLegacy(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal(["Alpha", "Beta"], result.Tags);
+ Assert.Equal(["Genre Leak"], result.Genres);
+ }
+
+ [Fact]
+ public void GetTagNames_GroupsAndFiltersItemValues()
+ {
+ var movie = CreateMovieEntity(Guid.NewGuid(), "Movie");
+ var otherMovie = CreateMovieEntity(Guid.NewGuid(), "Other Movie");
+ var audio = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Excluded Audio",
+ MediaType = "Audio",
+ IsMovie = false,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var tag = CreateItemValue(ItemValueType.Tags, "Alpha", "alpha");
+ var duplicateTag = CreateItemValue(ItemValueType.Tags, "alpha", "alpha");
+ var otherTag = CreateItemValue(ItemValueType.Tags, "Beta", "beta");
+ var inheritedTag = CreateItemValue(ItemValueType.InheritedTags, "Inherited", "inherited");
+ var genre = CreateItemValue(ItemValueType.Genre, "Genre Leak", "genre leak");
+ var excludedTag = CreateItemValue(ItemValueType.Tags, "Excluded Tag", "excluded tag");
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(movie, otherMovie, audio);
+ context.ItemValues.AddRange(tag, duplicateTag, otherTag, inheritedTag, genre, excludedTag);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, tag),
+ CreateMap(movie, duplicateTag),
+ CreateMap(otherMovie, otherTag),
+ CreateMap(movie, inheritedTag),
+ CreateMap(movie, genre),
+ CreateMap(audio, excludedTag));
+ context.SaveChanges();
+ }
+
+ var result = _repository.GetTagNames(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal(["Alpha", "Beta"], result);
+ }
+
+ [Fact]
+ public void GetGenreNames_GroupsAndFiltersMappedItemValues()
+ {
+ var movie = CreateMovieEntity(Guid.NewGuid(), "Movie");
+ var audio = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Audio",
+ MediaType = "Audio",
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var movieGenre = CreateItemValue(ItemValueType.Genre, "Movie Genre", "movie genre");
+ var duplicateMovieGenre = CreateItemValue(ItemValueType.Genre, "movie genre", "movie genre");
+ var musicGenre = CreateItemValue(ItemValueType.Genre, "Music Genre", "music genre");
+ var orphanedGenre = CreateItemValue(ItemValueType.Genre, "Orphaned Genre", "orphaned genre");
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(movie, audio);
+ context.ItemValues.AddRange(movieGenre, duplicateMovieGenre, musicGenre, orphanedGenre);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, movieGenre),
+ CreateMap(movie, duplicateMovieGenre),
+ CreateMap(audio, musicGenre));
+ context.SaveChanges();
+ }
+
+ Assert.Equal(["Movie Genre"], _repository.GetGenreNames());
+ Assert.Equal(["Music Genre"], _repository.GetMusicGenreNames());
+ }
+
+ private BaseItemEntity CreateMovieEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _movieTypeName,
+ Name = name,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue)
+ {
+ return new ItemValueMap
+ {
+ ItemId = item.Id,
+ ItemValueId = itemValue.ItemValueId,
+ Item = item,
+ ItemValue = itemValue
+ };
+ }
+
+ private static ItemValue CreateItemValue(ItemValueType type, string value, string cleanValue)
+ {
+ return new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = type,
+ Value = value,
+ CleanValue = cleanValue
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
new file mode 100644
index 0000000000..a9548a6d13
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedVersionTests.cs
@@ -0,0 +1,219 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the isPlayed filter over items with alternate versions: playback is recorded against the
+/// version that was actually played, so the played state belongs to the version group rather than to
+/// the row that happens to carry it.
+/// </summary>
+public sealed class BaseItemRepositoryPlayedVersionTests : SqliteDbTestFixture
+{
+ private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
+ private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+
+ private readonly BaseItemRepository _repository;
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+
+ private readonly Guid _playedViaAlternate = Guid.NewGuid();
+ private readonly Guid _playedOnPrimary = Guid.NewGuid();
+ private readonly Guid _unplayedWithAlternate = Guid.NewGuid();
+ private readonly Guid _unplayedWithoutAlternate = Guid.NewGuid();
+
+ private readonly Guid _seriesPlayedViaAlternate = Guid.NewGuid();
+ private readonly Guid _unplayedSeries = Guid.NewGuid();
+ private readonly Guid _seriesPlayedAcrossVersions = Guid.NewGuid();
+ private readonly Guid _partiallyPlayedSeries = Guid.NewGuid();
+
+ public BaseItemRepositoryPlayedVersionTests()
+ {
+ using (var context = CreateDbContext())
+ {
+ Seed(context);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [Fact]
+ public void IsPlayed_CountsAMoviePlayedThroughItsAlternateVersion()
+ {
+ Assert.Equal(
+ new HashSet<Guid> { _playedOnPrimary, _playedViaAlternate },
+ Ids(BaseItemKind.Movie, isPlayed: true));
+ }
+
+ [Fact]
+ public void IsUnplayed_DropsAMoviePlayedThroughItsAlternateVersion()
+ {
+ Assert.Equal(
+ new HashSet<Guid> { _unplayedWithAlternate, _unplayedWithoutAlternate },
+ Ids(BaseItemKind.Movie, isPlayed: false));
+ }
+
+ [Fact]
+ public void IsPlayed_KeepsAPlayedPrimaryWhoseAlternateHasNoRowOfItsOwn()
+ {
+ Assert.Contains(_playedOnPrimary, Ids(BaseItemKind.Movie, isPlayed: true));
+ }
+
+ [Fact]
+ public void IsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion()
+ {
+ Assert.Equal(
+ new HashSet<Guid> { _seriesPlayedViaAlternate, _seriesPlayedAcrossVersions },
+ Ids(BaseItemKind.Series, isPlayed: true));
+ Assert.Equal(
+ new HashSet<Guid> { _unplayedSeries, _partiallyPlayedSeries },
+ Ids(BaseItemKind.Series, isPlayed: false));
+ }
+
+ [Fact]
+ public void GetIsPlayed_CountsASeriesWatchedThroughAnEpisodeAlternateVersion()
+ {
+ Assert.True(_repository.GetIsPlayed(_user, _seriesPlayedViaAlternate, true));
+ Assert.False(_repository.GetIsPlayed(_user, _unplayedSeries, true));
+ }
+
+ [Fact]
+ public void IsResumable_DropsASeriesWhoseLastEpisodeWasPlayedThroughAnAlternateVersion()
+ {
+ var resumable = _repository.GetItemIdsList(new InternalItemsQuery(_user) { IsResumable = true });
+
+ // Nothing is left to watch, so the series is not half finished.
+ Assert.DoesNotContain(_seriesPlayedAcrossVersions, resumable);
+ Assert.Contains(_partiallyPlayedSeries, resumable);
+ }
+
+ private HashSet<Guid> Ids(BaseItemKind kind, bool isPlayed)
+ => _repository
+ .GetItemList(new InternalItemsQuery(_user)
+ {
+ IncludeItemTypes = [kind],
+ IsPlayed = isPlayed
+ })
+ .Select(i => i.Id)
+ .ToHashSet();
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.Users.Add(_user);
+
+ // Only the alternate carries the played row, which is what playing that version records.
+ AddMovieWithAlternate(context, _playedViaAlternate, "A", playedPrimary: false, playedAlternate: true);
+ AddMovieWithAlternate(context, _playedOnPrimary, "B", playedPrimary: true, playedAlternate: false);
+ AddMovieWithAlternate(context, _unplayedWithAlternate, "C", playedPrimary: false, playedAlternate: false);
+ AddItem(context, _unplayedWithoutAlternate, MovieType, "D");
+
+ AddSeriesWithAlternateEpisode(context, _seriesPlayedViaAlternate, "E", playedAlternate: true);
+ AddSeriesWithAlternateEpisode(context, _unplayedSeries, "F", playedAlternate: false);
+
+ AddSeriesWithTwoEpisodes(context, _seriesPlayedAcrossVersions, "G", secondPlayedViaAlternate: true);
+ AddSeriesWithTwoEpisodes(context, _partiallyPlayedSeries, "H", secondPlayedViaAlternate: false);
+
+ context.SaveChanges();
+ }
+
+ private void AddMovieWithAlternate(JellyfinDbContext context, Guid primaryId, string name, bool playedPrimary, bool playedAlternate)
+ {
+ AddItem(context, primaryId, MovieType, name);
+ AddAlternateVersion(context, primaryId, MovieType, $"{name} 4K", playedAlternate);
+
+ if (playedPrimary)
+ {
+ AddPlayedUserData(context, primaryId);
+ }
+ }
+
+ private void AddSeriesWithAlternateEpisode(JellyfinDbContext context, Guid seriesId, string name, bool playedAlternate)
+ {
+ var episodeId = Guid.NewGuid();
+
+ AddSeriesFolder(context, seriesId, name);
+
+ AddItem(context, episodeId, EpisodeType, $"{name} 1");
+ context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+
+ AddAlternateVersion(context, episodeId, EpisodeType, $"{name} 1 4K", playedAlternate);
+ }
+
+ // A watched first episode plus a second one that is either watched as its alternate version or not
+ // watched at all, which is what separates a finished series from a half watched one.
+ private void AddSeriesWithTwoEpisodes(JellyfinDbContext context, Guid seriesId, string name, bool secondPlayedViaAlternate)
+ {
+ AddSeriesFolder(context, seriesId, name);
+
+ var firstId = Guid.NewGuid();
+ AddItem(context, firstId, EpisodeType, $"{name} 1");
+ context.AncestorIds.Add(new AncestorId { ItemId = firstId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+ AddPlayedUserData(context, firstId);
+
+ var secondId = Guid.NewGuid();
+ AddItem(context, secondId, EpisodeType, $"{name} 2");
+ context.AncestorIds.Add(new AncestorId { ItemId = secondId, ParentItemId = seriesId, Item = null!, ParentItem = null! });
+ AddAlternateVersion(context, secondId, EpisodeType, $"{name} 2 4K", secondPlayedViaAlternate);
+ }
+
+ private void AddSeriesFolder(JellyfinDbContext context, Guid seriesId, string name)
+ => context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = seriesId,
+ Type = SeriesType,
+ Name = name,
+ SortName = name,
+ PresentationUniqueKey = seriesId.ToString("N"),
+ IsFolder = true
+ });
+
+ private void AddItem(JellyfinDbContext context, Guid id, string type, string name)
+ => context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = type,
+ Name = name,
+ SortName = name,
+ PresentationUniqueKey = id.ToString("N")
+ });
+
+ private void AddAlternateVersion(JellyfinDbContext context, Guid primaryId, string type, string name, bool played)
+ {
+ var alternateId = Guid.NewGuid();
+
+ // An alternate presents under its primary's key, which is what collapses the group in listings.
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = alternateId,
+ Type = type,
+ Name = name,
+ SortName = name,
+ PresentationUniqueKey = primaryId.ToString("N"),
+ PrimaryVersionId = primaryId
+ });
+
+ if (played)
+ {
+ AddPlayedUserData(context, alternateId);
+ }
+ }
+
+ private void AddPlayedUserData(JellyfinDbContext context, Guid itemId)
+ => context.UserData.Add(new UserData
+ {
+ ItemId = itemId,
+ UserId = _user.Id,
+ CustomDataKey = itemId.ToString("N"),
+ Played = true,
+ Item = null!,
+ User = null!
+ });
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
index fea743f08e..787bb24150 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
+using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
@@ -12,6 +15,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -25,6 +29,8 @@ public sealed class ItemCountServiceTests : IDisposable
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly IApplicationPaths _applicationPaths;
private readonly ItemCountService _service;
+ private int _contextsCreated;
+ private List<string>? _capturedSql;
public ItemCountServiceTests()
{
@@ -35,6 +41,7 @@ public sealed class ItemCountServiceTests : IDisposable
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
+ .LogTo(CaptureStatement, LogLevel.Information)
.Options;
using (var context = CreateDbContext())
@@ -43,7 +50,11 @@ public sealed class ItemCountServiceTests : IDisposable
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
- factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContext()).Returns(() =>
+ {
+ _contextsCreated++;
+ return CreateDbContext();
+ });
var queryHelpers = new Mock<IItemQueryHelpers>();
queryHelpers
@@ -53,9 +64,25 @@ public sealed class ItemCountServiceTests : IDisposable
It.IsAny<InternalItemsQuery>()))
.Returns((JellyfinDbContext _, IQueryable<BaseItemEntity> query, InternalItemsQuery _) => query);
+ var typeLookup = new Mock<IItemTypeLookup>();
+ typeLookup.Setup(l => l.BaseItemKindNames).Returns(new Dictionary<BaseItemKind, string>
+ {
+ [BaseItemKind.Movie] = "Movie",
+ [BaseItemKind.Series] = "Series",
+ [BaseItemKind.Episode] = "Episode",
+ [BaseItemKind.MusicAlbum] = "MusicAlbum",
+ [BaseItemKind.MusicArtist] = "MusicArtist",
+ [BaseItemKind.MusicVideo] = "MusicVideo",
+ [BaseItemKind.Audio] = "Audio",
+ [BaseItemKind.Trailer] = "Trailer",
+ [BaseItemKind.BoxSet] = "BoxSet",
+ [BaseItemKind.Book] = "Book",
+ [BaseItemKind.LiveTvProgram] = "LiveTvProgram"
+ });
+
_service = new ItemCountService(
factory.Object,
- new Mock<IItemTypeLookup>().Object,
+ typeLookup.Object,
queryHelpers.Object);
}
@@ -64,6 +91,14 @@ public sealed class ItemCountServiceTests : IDisposable
_connection.Dispose();
}
+ private void CaptureStatement(string message)
+ {
+ if (_capturedSql is not null && message.Contains("SELECT", StringComparison.Ordinal))
+ {
+ _capturedSql.Add(message[message.IndexOf("SELECT", StringComparison.Ordinal)..]);
+ }
+ }
+
[Fact]
public void GetChildCountBatch_LargeParentIdSet_DoesNotExceedSqliteVariableLimit()
{
@@ -166,6 +201,201 @@ public sealed class ItemCountServiceTests : IDisposable
}
[Fact]
+ public void GetCounts_PlayedAlternateVersion_CountThePrimaryAsPlayed()
+ {
+ var user = new User("alt-version-test", "provider", "reset");
+ var seriesId = Guid.NewGuid();
+ var primaryId = Guid.NewGuid();
+ var alternateId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(user);
+
+ var series = CreateItem(seriesId);
+ series.PresentationUniqueKey = "alt-version-series";
+ context.BaseItems.Add(series);
+
+ context.BaseItems.Add(CreateLeaf(primaryId));
+ var alternate = CreateLeaf(alternateId);
+ alternate.PrimaryVersionId = primaryId;
+ context.BaseItems.Add(alternate);
+ context.SaveChanges();
+
+ // Only the primary is counted as a leaf, as ApplyAccessFiltering leaves it in production.
+ AddAncestor(context, primaryId, seriesId);
+
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = primaryId,
+ ChildId = alternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ // The file that was watched is the alternate, so the primary carries no played row.
+ context.UserData.Add(new UserData
+ {
+ ItemId = alternateId,
+ UserId = user.Id,
+ CustomDataKey = string.Empty,
+ Played = true,
+ Item = null,
+ User = null
+ });
+
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery(user);
+
+ // The per-item paths have to agree with the batch one, which the DTO uses interchangeably.
+ Assert.Equal(1, _service.GetPlayedCount(filter, seriesId));
+ Assert.Equal((1, 1), _service.GetPlayedAndTotalCount(filter, seriesId));
+ Assert.Equal((1, 1), _service.GetPlayedAndTotalCountBatch([seriesId], user)[seriesId]);
+ }
+
+ [Fact]
+ public void GetCounts_MultiVersionMovie_CountPlaybackOfAnyVersion()
+ {
+ // Two movies held as two files each: the primary the collection links, and an alternate version
+ // linked to it. One movie was watched on its alternate, which is where playback of a second cut
+ // lands; the other was not watched at all.
+ var user = new User("alt-version-test", "provider", "reset");
+ var boxSetId = Guid.NewGuid();
+ var libraryId = Guid.NewGuid();
+ var watchedPrimaryId = Guid.NewGuid();
+ var watchedAlternateId = Guid.NewGuid();
+ var unwatchedPrimaryId = Guid.NewGuid();
+ var unwatchedAlternateId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(user);
+
+ var boxSet = CreateItem(boxSetId);
+ boxSet.PresentationUniqueKey = "alt-version-box-set";
+ context.BaseItems.Add(boxSet);
+
+ var library = CreateItem(libraryId);
+ library.PresentationUniqueKey = "alt-version-library";
+ context.BaseItems.Add(library);
+
+ foreach (var (primaryId, alternateId) in
+ new[] { (watchedPrimaryId, watchedAlternateId), (unwatchedPrimaryId, unwatchedAlternateId) })
+ {
+ context.BaseItems.Add(CreateLeaf(primaryId));
+
+ var alternate = CreateLeaf(alternateId);
+ alternate.PrimaryVersionId = primaryId;
+ context.BaseItems.Add(alternate);
+ }
+
+ context.SaveChanges();
+
+ context.LinkedChildren.AddRange(
+ new LinkedChildEntity
+ {
+ ParentId = boxSetId,
+ ChildId = watchedPrimaryId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 0
+ },
+ new LinkedChildEntity
+ {
+ ParentId = boxSetId,
+ ChildId = unwatchedPrimaryId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = 1
+ },
+ new LinkedChildEntity
+ {
+ ParentId = watchedPrimaryId,
+ ChildId = watchedAlternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ },
+ new LinkedChildEntity
+ {
+ ParentId = unwatchedPrimaryId,
+ ChildId = unwatchedAlternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ AddAncestor(context, watchedPrimaryId, libraryId);
+ AddAncestor(context, unwatchedPrimaryId, libraryId);
+
+ context.UserData.Add(new UserData
+ {
+ ItemId = watchedAlternateId,
+ UserId = user.Id,
+ CustomDataKey = string.Empty,
+ Played = true,
+ Item = null,
+ User = null
+ });
+
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery(user);
+
+ // A version group is one item to count, and the alternate's playback makes that item played -
+ // as it already does for the played flag the primary itself reports.
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCountFromLinkedChildren(filter, boxSetId));
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCountBatch([boxSetId], user)[boxSetId]);
+
+ // The ancestor-based paths answer the same for the library the primaries sit in.
+ Assert.Equal(1, _service.GetPlayedCount(filter, libraryId));
+ Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, libraryId));
+ }
+
+ [Fact]
+ public void GetChildCountBatch_NoUser_StillCollapsesAlternateVersions()
+ {
+ // Both files of a merged movie sit in the folder. With a user it is access filtering that
+ // drops the alternate; with no user nothing else would, and the folder would report two
+ // children for the one title a viewer sees.
+ var folderId = Guid.NewGuid();
+ var primaryId = Guid.NewGuid();
+ var alternateId = Guid.NewGuid();
+ var extraId = Guid.NewGuid();
+ var ownedId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(CreateItem(folderId));
+
+ var primary = CreateLeaf(primaryId);
+ primary.ParentId = folderId;
+ context.BaseItems.Add(primary);
+
+ var alternate = CreateLeaf(alternateId);
+ alternate.ParentId = folderId;
+ alternate.PrimaryVersionId = primaryId;
+ context.BaseItems.Add(alternate);
+
+ // An extra carries an owner and an extra type, and stays a child of its own.
+ var extra = CreateLeaf(extraId);
+ extra.ParentId = folderId;
+ extra.OwnerId = primaryId;
+ extra.ExtraType = BaseItemExtraType.Trailer;
+ context.BaseItems.Add(extra);
+
+ // An owned item that is not an extra belongs to its owner, not to the folder.
+ var owned = CreateLeaf(ownedId);
+ owned.ParentId = folderId;
+ owned.OwnerId = primaryId;
+ context.BaseItems.Add(owned);
+
+ context.SaveChanges();
+ }
+
+ Assert.Equal(2, _service.GetChildCountBatch([folderId], null)[folderId]);
+ }
+
+ [Fact]
public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys()
{
var seriesA = Guid.NewGuid();
@@ -335,6 +565,695 @@ public sealed class ItemCountServiceTests : IDisposable
};
}
+ [Fact]
+ public void GetItemCountsForNameItems_MatchesCountingEachNameItemOnItsOwn()
+ {
+ // Three genres tagging a different number of movies each, plus one tagging nothing.
+ var genres = SeedGenres();
+
+ var filter = new InternalItemsQuery();
+ BaseItemKind[] related = [BaseItemKind.Movie, BaseItemKind.Series];
+
+ var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter);
+
+ // Every requested id is answered, so a caller can index the result without checking.
+ Assert.Equal(genres.Count, batch.Count);
+
+ foreach (var genreId in genres)
+ {
+ var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter);
+
+ Assert.Equal(single.MovieCount, batch[genreId].MovieCount);
+ Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount);
+ Assert.Equal(single.ItemCount, batch[genreId].ItemCount);
+ }
+
+ // And the counts are the seeded ones rather than all zero, which would match trivially.
+ Assert.Equal([3, 2, 1, 0], genres.Select(g => batch[g].MovieCount).ToArray());
+ }
+
+ [Fact]
+ public void GetItemCountsForNameItems_UnknownId_CountsZero()
+ {
+ var unknown = Guid.NewGuid();
+
+ var batch = _service.GetItemCountsForNameItems(
+ BaseItemKind.Genre,
+ [unknown],
+ [BaseItemKind.Movie],
+ new InternalItemsQuery());
+
+ Assert.Equal(0, batch[unknown].ItemCount);
+ }
+
+ [Fact]
+ public void GetItemCountsForNameItems_ArtistTaggedTwiceOnOneAlbum_CountsTheAlbumOnce()
+ {
+ // An album whose artist is also its album artist maps to the same artist twice.
+ var artistId = SeedArtistWithAlbum();
+
+ var filter = new InternalItemsQuery();
+ BaseItemKind[] related = [BaseItemKind.MusicAlbum];
+
+ var batch = _service.GetItemCountsForNameItems(BaseItemKind.MusicArtist, [artistId], related, filter);
+ var single = _service.GetItemCountsForNameItem(BaseItemKind.MusicArtist, artistId, related, filter);
+
+ Assert.Equal(1, batch[artistId].AlbumCount);
+ Assert.Equal(single.AlbumCount, batch[artistId].AlbumCount);
+ Assert.Equal(single.ItemCount, batch[artistId].ItemCount);
+ }
+
+ /// <summary>
+ /// Seeds one artist and a single album tagged with it as both artist and album artist.
+ /// </summary>
+ /// <returns>The id of the seeded artist.</returns>
+ private Guid SeedArtistWithAlbum()
+ {
+ const string Name = "artist-0";
+ var artistId = Guid.NewGuid();
+ var albumId = Guid.NewGuid();
+
+ using var context = CreateDbContext();
+
+ var artist = CreateItem(artistId);
+ artist.Type = "MusicArtist";
+ artist.Name = Name;
+ artist.CleanName = Name;
+ context.BaseItems.Add(artist);
+
+ var album = CreateItem(albumId);
+ album.Type = "MusicAlbum";
+ context.BaseItems.Add(album);
+ context.SaveChanges();
+
+ foreach (var type in new[] { ItemValueType.Artist, ItemValueType.AlbumArtist })
+ {
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = type,
+ Value = Name,
+ CleanValue = Name
+ };
+ context.ItemValues.Add(itemValue);
+ context.SaveChanges();
+
+ context.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = albumId,
+ ItemValueId = itemValue.ItemValueId,
+ Item = null!,
+ ItemValue = null!
+ });
+ }
+
+ context.SaveChanges();
+
+ return artistId;
+ }
+
+ [Fact]
+ public void GetItemCountsForNameItems_LargeIdSet_DoesNotExceedSqliteVariableLimit()
+ {
+ // Seeded rather than random, so the clean names of every one of them reach the second
+ // query's IN list and the join behind it, instead of stopping at the empty-name return.
+ var seeded = SeedArtists(50, out var taggedArtistId);
+
+ var ids = seeded.Concat(Enumerable.Range(0, 40_000).Select(_ => Guid.NewGuid())).ToList();
+
+ var batch = _service.GetItemCountsForNameItems(
+ BaseItemKind.MusicArtist,
+ ids,
+ [BaseItemKind.MusicAlbum],
+ new InternalItemsQuery());
+
+ Assert.Equal(ids.Count, batch.Count);
+
+ // And the grouped query really ran, rather than every id coming back zeroed.
+ Assert.Equal(1, batch[taggedArtistId].AlbumCount);
+ }
+
+ [Fact]
+ public void GetItemCountsForNameItems_QueryShape_DoesNotVaryWithBatchSize()
+ {
+ // Every id list has to be bound as one parameter rather than one placeholder each: that is
+ // what keeps the statement off the SQLite variable ceiling and out of a per-size entry in
+ // EF's compiled query cache. Identical SQL for two batch sizes is exactly that property.
+ var seeded = SeedArtists(6, out _);
+
+ var small = CaptureSql(() => _service.GetItemCountsForNameItems(
+ BaseItemKind.MusicArtist, seeded.Take(2).ToList(), [BaseItemKind.MusicAlbum], new InternalItemsQuery()));
+
+ var large = CaptureSql(() => _service.GetItemCountsForNameItems(
+ BaseItemKind.MusicArtist, seeded, [BaseItemKind.MusicAlbum], new InternalItemsQuery()));
+
+ Assert.NotEmpty(small);
+ Assert.Equal(small, large);
+ }
+
+ private List<string> CaptureSql(Action action)
+ {
+ _capturedSql = [];
+ try
+ {
+ action();
+ return _capturedSql;
+ }
+ finally
+ {
+ _capturedSql = null;
+ }
+ }
+
+ /// <summary>
+ /// Seeds the requested number of artists, each with a clean name of its own, one of which is
+ /// credited on a single album.
+ /// </summary>
+ /// <param name="count">The number of artists to seed.</param>
+ /// <param name="taggedArtistId">The id of the artist credited on an album.</param>
+ /// <returns>The ids of the seeded artists.</returns>
+ private List<Guid> SeedArtists(int count, out Guid taggedArtistId)
+ {
+ var ids = new List<Guid>(count);
+ using var context = CreateDbContext();
+
+ ItemValue? taggedValue = null;
+ taggedArtistId = Guid.Empty;
+
+ for (var i = 0; i < count; i++)
+ {
+ var name = "bulk-artist-" + i.ToString(CultureInfo.InvariantCulture);
+ var artistId = Guid.NewGuid();
+ ids.Add(artistId);
+
+ var artist = CreateItem(artistId);
+ artist.Type = "MusicArtist";
+ artist.Name = name;
+ artist.CleanName = name;
+ context.BaseItems.Add(artist);
+
+ if (i == 0)
+ {
+ taggedArtistId = artistId;
+ taggedValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Artist,
+ Value = name,
+ CleanValue = name
+ };
+ context.ItemValues.Add(taggedValue);
+ }
+ }
+
+ context.SaveChanges();
+
+ var albumId = Guid.NewGuid();
+ var album = CreateItem(albumId);
+ album.Type = "MusicAlbum";
+ context.BaseItems.Add(album);
+ context.SaveChanges();
+
+ Tag(context, albumId, taggedValue!.ItemValueId);
+ context.SaveChanges();
+
+ return ids;
+ }
+
+ [Fact]
+ public void GetItemCountsForNameItems_KindWithoutItemValues_FallsBackToTheSingleItemPath()
+ {
+ // Year is keyed by ProductionYear rather than a cleaned item value, so it cannot be grouped.
+ var yearId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ var year = CreateItem(yearId);
+ year.Type = "Year";
+ year.Name = "2001";
+ year.CleanName = "2001";
+ context.BaseItems.Add(year);
+
+ for (var i = 0; i < 2; i++)
+ {
+ var movie = CreateItem(Guid.NewGuid());
+ movie.Type = "Movie";
+ movie.IsFolder = false;
+ movie.ProductionYear = 2001;
+ context.BaseItems.Add(movie);
+ }
+
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery();
+ BaseItemKind[] related = [BaseItemKind.Movie];
+
+ var batch = _service.GetItemCountsForNameItems(BaseItemKind.Year, [yearId], related, filter);
+ var single = _service.GetItemCountsForNameItem(BaseItemKind.Year, yearId, related, filter);
+
+ Assert.Equal(2, batch[yearId].MovieCount);
+ Assert.Equal(single.MovieCount, batch[yearId].MovieCount);
+ }
+
+ [Fact]
+ public void GetItemCountsForNameItems_PeopleAndYears_AreBatchedToo()
+ {
+ var (personIds, yearIds) = SeedPeopleAndYears();
+
+ var filter = new InternalItemsQuery();
+ BaseItemKind[] related = [BaseItemKind.Movie];
+
+ foreach (var (kind, ids) in new[] { (BaseItemKind.Person, personIds), (BaseItemKind.Year, yearIds) })
+ {
+ var contextsBefore = _contextsCreated;
+ var batch = _service.GetItemCountsForNameItems(kind, ids, related, filter);
+
+ // These two used to be answered one query per id; only the value keyed kinds batched.
+ Assert.Equal(1, _contextsCreated - contextsBefore);
+
+ Assert.Equal(ids.Count, batch.Count);
+ Assert.Equal(2, batch[ids[0]].MovieCount);
+ Assert.Equal(1, batch[ids[1]].MovieCount);
+
+ foreach (var id in ids)
+ {
+ var single = _service.GetItemCountsForNameItem(kind, id, related, filter);
+ Assert.Equal(single.MovieCount, batch[id].MovieCount);
+ Assert.Equal(single.ItemCount, batch[id].ItemCount);
+ }
+ }
+ }
+
+ /// <summary>
+ /// Seeds two people and two years, the first of each on two movies and the second on one.
+ /// </summary>
+ /// <returns>The ids of the seeded people and years.</returns>
+ private (List<Guid> PersonIds, List<Guid> YearIds) SeedPeopleAndYears()
+ {
+ var personIds = new List<Guid>();
+ var yearIds = new List<Guid>();
+
+ using var context = CreateDbContext();
+
+ for (var i = 0; i < 2; i++)
+ {
+ var personName = "person-" + i.ToString(CultureInfo.InvariantCulture);
+ var personId = Guid.NewGuid();
+ personIds.Add(personId);
+
+ var person = CreateItem(personId);
+ person.Type = "Person";
+ person.Name = personName;
+ person.CleanName = personName;
+ context.BaseItems.Add(person);
+
+ var people = new People { Id = Guid.NewGuid(), Name = personName };
+ context.Peoples.Add(people);
+
+ var year = 2000 + i;
+ var yearId = Guid.NewGuid();
+ yearIds.Add(yearId);
+
+ var yearItem = CreateItem(yearId);
+ yearItem.Type = "Year";
+ yearItem.Name = year.ToString(CultureInfo.InvariantCulture);
+ yearItem.CleanName = yearItem.Name;
+ context.BaseItems.Add(yearItem);
+ context.SaveChanges();
+
+ // Two movies for the first of each, one for the second.
+ for (var m = 0; m < 2 - i; m++)
+ {
+ var movieId = Guid.NewGuid();
+ var movie = CreateItem(movieId);
+ movie.Type = "Movie";
+ movie.IsFolder = false;
+ movie.ProductionYear = year;
+ context.BaseItems.Add(movie);
+ context.SaveChanges();
+
+ context.PeopleBaseItemMap.Add(new PeopleBaseItemMap
+ {
+ ItemId = movieId,
+ PeopleId = people.Id,
+ Item = null!,
+ People = null!,
+ Role = "Actor",
+ ListOrder = m,
+ SortOrder = m
+ });
+ }
+
+ context.SaveChanges();
+ }
+
+ return (personIds, yearIds);
+ }
+
+ [Theory]
+ // The set the by-name listing actually asks for: it rolls the episodes of a tagged series up
+ // into the genre, which is the case the batch has to reproduce query for query.
+ [InlineData(BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie)]
+ // And the same seeded data without the roll-up, which takes the plain grouped path.
+ [InlineData(BaseItemKind.Movie, BaseItemKind.Series, BaseItemKind.MusicAlbum)]
+ public void GetItemCountsForNameItems_TaggedSeriesAndEpisodes_MatchesCountingEachNameItemOnItsOwn(
+ BaseItemKind first,
+ BaseItemKind second,
+ BaseItemKind third)
+ {
+ var genres = SeedGenresTaggingSeriesAndEpisodes();
+
+ var filter = new InternalItemsQuery();
+ BaseItemKind[] related = [first, second, third];
+
+ var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genres, related, filter);
+
+ Assert.Equal(genres.Count, batch.Count);
+
+ foreach (var genreId in genres)
+ {
+ var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter);
+
+ Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount);
+ Assert.Equal(single.SeriesCount, batch[genreId].SeriesCount);
+ Assert.Equal(single.MovieCount, batch[genreId].MovieCount);
+ Assert.Equal(single.ItemCount, batch[genreId].ItemCount);
+ }
+ }
+
+ [Fact]
+ public void GetItemCountsForNameItems_TaggedSeries_RollsEpisodesUpIntoTheGenre()
+ {
+ var genres = SeedGenresTaggingSeriesAndEpisodes();
+
+ var contextsBefore = _contextsCreated;
+
+ var batch = _service.GetItemCountsForNameItems(
+ BaseItemKind.Genre,
+ genres,
+ [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie],
+ new InternalItemsQuery());
+
+ // The whole point of the batch: one context for every genre on the page, not one each.
+ // The roll-up used to force this shape back onto the single item path.
+ Assert.Equal(1, _contextsCreated - contextsBefore);
+
+ // "rolled": one tagged series of two episodes, one of which carries the genre itself, plus
+ // a loose tagged episode of an untagged series. The tagged episode of the tagged series
+ // must not be counted twice.
+ Assert.Equal(3, batch[genres[0]].EpisodeCount);
+ Assert.Equal(1, batch[genres[0]].SeriesCount);
+
+ // "loose": a tagged episode whose series carries no genre at all.
+ Assert.Equal(1, batch[genres[1]].EpisodeCount);
+ Assert.Equal(0, batch[genres[1]].SeriesCount);
+
+ // "empty": tags nothing.
+ Assert.Equal(0, batch[genres[2]].EpisodeCount);
+ }
+
+ [Fact]
+ public void GetItemCountsForNameItems_EpisodeAndItsSeriesTaggedDifferently_KeepsTheGenresApart()
+ {
+ var seriesId = Guid.NewGuid();
+ var episodeId = Guid.NewGuid();
+ var genreIds = new List<Guid>();
+
+ using (var context = CreateDbContext())
+ {
+ var values = new Dictionary<string, Guid>(StringComparer.Ordinal);
+ foreach (var name in new[] { "on-series", "on-episode" })
+ {
+ var genreId = Guid.NewGuid();
+ genreIds.Add(genreId);
+
+ var genre = CreateItem(genreId);
+ genre.Type = "Genre";
+ genre.Name = name;
+ genre.CleanName = name;
+ context.BaseItems.Add(genre);
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = name,
+ CleanValue = name
+ };
+ context.ItemValues.Add(itemValue);
+ values[name] = itemValue.ItemValueId;
+ }
+
+ var series = CreateItem(seriesId);
+ series.Type = "Series";
+ context.BaseItems.Add(series);
+ context.BaseItems.Add(CreateEpisode(episodeId, seriesId));
+ context.SaveChanges();
+
+ Tag(context, seriesId, values["on-series"]);
+ Tag(context, episodeId, values["on-episode"]);
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery();
+ BaseItemKind[] related = [BaseItemKind.Episode, BaseItemKind.Series, BaseItemKind.Movie];
+
+ var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, genreIds, related, filter);
+
+ // The episode rolls up into the genre on its series.
+ Assert.Equal(1, batch[genreIds[0]].EpisodeCount);
+
+ // Its own genre is carried by no series, so the episode stays a direct count there. Keyed
+ // on the series id alone the episode would be subtracted here and this would read 0.
+ Assert.Equal(1, batch[genreIds[1]].EpisodeCount);
+ Assert.Equal(0, batch[genreIds[1]].SeriesCount);
+
+ foreach (var genreId in genreIds)
+ {
+ var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter);
+ Assert.Equal(single.EpisodeCount, batch[genreId].EpisodeCount);
+ Assert.Equal(single.ItemCount, batch[genreId].ItemCount);
+ }
+ }
+
+ [Fact]
+ public void GetItemCountsForNameItems_TwoNameItemsSharingACleanName_BothGetTheCounts()
+ {
+ // Distinct rows cleaning down to one name are what the batch keys on; the unique index
+ // permits them, so two genre items can legitimately share a clean name.
+ var firstId = Guid.NewGuid();
+ var secondId = Guid.NewGuid();
+ var movieId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ foreach (var (id, name) in new[] { (firstId, "Sci-Fi"), (secondId, "SCI-FI") })
+ {
+ var genre = CreateItem(id);
+ genre.Type = "Genre";
+ genre.Name = name;
+ genre.CleanName = "sci-fi";
+ context.BaseItems.Add(genre);
+ }
+
+ var movie = CreateItem(movieId);
+ movie.Type = "Movie";
+ movie.IsFolder = false;
+ context.BaseItems.Add(movie);
+ context.SaveChanges();
+
+ foreach (var name in new[] { "Sci-Fi", "SCI-FI" })
+ {
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = name,
+ CleanValue = "sci-fi"
+ };
+ context.ItemValues.Add(itemValue);
+ context.SaveChanges();
+ Tag(context, movieId, itemValue.ItemValueId);
+ }
+
+ context.SaveChanges();
+ }
+
+ var filter = new InternalItemsQuery();
+ BaseItemKind[] related = [BaseItemKind.Movie];
+
+ var batch = _service.GetItemCountsForNameItems(BaseItemKind.Genre, [firstId, secondId], related, filter);
+
+ // One movie, reached through two value rows: counted once for each genre item, not twice.
+ Assert.Equal(1, batch[firstId].MovieCount);
+ Assert.Equal(1, batch[secondId].MovieCount);
+
+ foreach (var genreId in new[] { firstId, secondId })
+ {
+ var single = _service.GetItemCountsForNameItem(BaseItemKind.Genre, genreId, related, filter);
+ Assert.Equal(single.MovieCount, batch[genreId].MovieCount);
+ }
+ }
+
+ /// <summary>
+ /// Seeds three genres: one tagging a series whose episodes roll up (one of them tagged too)
+ /// plus a loose episode, one tagging only an episode of an untagged series, and one tagging
+ /// nothing.
+ /// </summary>
+ /// <returns>The ids of the seeded genres, in that order.</returns>
+ private List<Guid> SeedGenresTaggingSeriesAndEpisodes()
+ {
+ var genreIds = new List<Guid>();
+
+ using var context = CreateDbContext();
+
+ var values = new Dictionary<string, Guid>(StringComparer.Ordinal);
+ foreach (var name in new[] { "rolled", "loose", "empty" })
+ {
+ var genreId = Guid.NewGuid();
+ genreIds.Add(genreId);
+
+ var genre = CreateItem(genreId);
+ genre.Type = "Genre";
+ genre.Name = name;
+ genre.CleanName = name;
+ context.BaseItems.Add(genre);
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = name,
+ CleanValue = name
+ };
+ context.ItemValues.Add(itemValue);
+ values[name] = itemValue.ItemValueId;
+ }
+
+ context.SaveChanges();
+
+ // A series tagged "rolled" holding two episodes; the second carries "rolled" itself, so the
+ // roll-up and the direct tag both see it.
+ var taggedSeriesId = Guid.NewGuid();
+ var taggedSeries = CreateItem(taggedSeriesId);
+ taggedSeries.Type = "Series";
+ context.BaseItems.Add(taggedSeries);
+
+ var episodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId);
+ var taggedEpisodeOfTaggedSeries = CreateEpisode(Guid.NewGuid(), taggedSeriesId);
+ context.BaseItems.AddRange(episodeOfTaggedSeries, taggedEpisodeOfTaggedSeries);
+
+ // An untagged series whose episode carries a genre on its own.
+ var untaggedSeriesId = Guid.NewGuid();
+ var untaggedSeries = CreateItem(untaggedSeriesId);
+ untaggedSeries.Type = "Series";
+ context.BaseItems.Add(untaggedSeries);
+
+ var looseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId);
+ var rolledLooseEpisode = CreateEpisode(Guid.NewGuid(), untaggedSeriesId);
+ context.BaseItems.AddRange(looseEpisode, rolledLooseEpisode);
+
+ var movieId = Guid.NewGuid();
+ var movie = CreateItem(movieId);
+ movie.Type = "Movie";
+ movie.IsFolder = false;
+ context.BaseItems.Add(movie);
+
+ context.SaveChanges();
+
+ Tag(context, taggedSeriesId, values["rolled"]);
+ Tag(context, taggedEpisodeOfTaggedSeries.Id, values["rolled"]);
+ Tag(context, rolledLooseEpisode.Id, values["rolled"]);
+ Tag(context, looseEpisode.Id, values["loose"]);
+ Tag(context, movieId, values["rolled"]);
+
+ context.SaveChanges();
+
+ return genreIds;
+ }
+
+ private static void Tag(JellyfinDbContext context, Guid itemId, Guid itemValueId)
+ {
+ context.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = itemId,
+ ItemValueId = itemValueId,
+ Item = null!,
+ ItemValue = null!
+ });
+ }
+
+ private static BaseItemEntity CreateEpisode(Guid id, Guid seriesId)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = "Episode",
+ IsFolder = false,
+ IsVirtualItem = false,
+ ParentId = seriesId,
+ SeriesId = seriesId
+ };
+ }
+
+ /// <summary>
+ /// Seeds four genres tagging three, two, one and no movies, in that order.
+ /// </summary>
+ /// <returns>The ids of the seeded genres.</returns>
+ private List<Guid> SeedGenres()
+ {
+ var genreIds = new List<Guid>();
+
+ using var context = CreateDbContext();
+
+ for (var i = 0; i < 4; i++)
+ {
+ var name = "genre-" + i.ToString(CultureInfo.InvariantCulture);
+ var genreId = Guid.NewGuid();
+ genreIds.Add(genreId);
+
+ var genre = CreateItem(genreId);
+ genre.Type = "Genre";
+ genre.Name = name;
+ genre.CleanName = name;
+ context.BaseItems.Add(genre);
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = name,
+ CleanValue = name
+ };
+ context.ItemValues.Add(itemValue);
+ context.SaveChanges();
+
+ // 3 movies for the first genre, 2 for the second, 1 for the third, none for the last.
+ for (var m = 0; m < 3 - i; m++)
+ {
+ var movieId = Guid.NewGuid();
+ var movie = CreateItem(movieId);
+ movie.Type = "Movie";
+ movie.IsFolder = false;
+ context.BaseItems.Add(movie);
+ context.SaveChanges();
+
+ context.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = movieId,
+ ItemValueId = itemValue.ItemValueId,
+ Item = null!,
+ ItemValue = null!
+ });
+ }
+
+ context.SaveChanges();
+ }
+
+ return genreIds;
+ }
+
private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null)
{
return new BaseItemEntity
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs
new file mode 100644
index 0000000000..c15ea09965
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceAlternateVersionTests.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using DbLinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers the invariant that a video linked as an alternate version also carries the
+/// PrimaryVersionId the item queries hide it by, including when it was already a library
+/// item in its own right before it became a version.
+/// </summary>
+public sealed class ItemPersistenceAlternateVersionTests : SqliteDbTestFixture
+{
+ private const string PrimaryPath = "/movies/Movie/Movie - 4K.mkv";
+ private const string VersionPath = "/movies/Movie/Movie - 1080p.mkv";
+
+ private readonly ItemPersistenceService _service;
+ private readonly ILibraryManager? _previousLibraryManager;
+ private readonly IServerConfigurationManager? _previousConfigurationManager;
+ private readonly IRecordingsManager? _previousRecordingsManager;
+
+ public ItemPersistenceAlternateVersionTests()
+ {
+ // BaseItem resolves these through process-wide statics; restored in Dispose.
+ _previousLibraryManager = BaseItem.LibraryManager;
+ _previousConfigurationManager = BaseItem.ConfigurationManager;
+ _previousRecordingsManager = Video.RecordingsManager;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>()))
+ .Returns([]);
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ BaseItem.ConfigurationManager = configurationManager.Object;
+
+ // Video.SourceType asks this whether the file is an in-progress recording.
+ Video.RecordingsManager = new Mock<IRecordingsManager>().Object;
+
+ // Paths round-trip through the host's virtual path mapping on the way in and out.
+ var appHost = new Mock<IServerApplicationHost>();
+ appHost.Setup(h => h.ReverseVirtualPath(It.IsAny<string>())).Returns((string p) => p);
+ appHost.Setup(h => h.ExpandVirtualPath(It.IsAny<string>())).Returns((string p) => p);
+
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ appHost.Object,
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ BaseItem.ConfigurationManager = _previousConfigurationManager!;
+ Video.RecordingsManager = _previousRecordingsManager!;
+ base.Dispose(disposing);
+ }
+
+ [Fact]
+ public void SaveItems_LocalAlternateVersionAlreadyAnItem_SetsPrimaryVersionId()
+ {
+ var primaryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ var versionId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ // The version was scanned as a standalone movie before it became a version, so it has a
+ // presentation key of its own and no PrimaryVersionId.
+ var version = CreateMovie(versionId, VersionPath);
+ version.PresentationUniqueKey = "standalone";
+ _service.SaveItems([version], CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId);
+ }
+
+ // Now the scan folds it into a primary, which is the item that gets saved.
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LocalAlternateVersions = [VersionPath];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId)));
+ Assert.Equal(DbLinkedChildType.LocalAlternateVersion, link.ChildType);
+ Assert.Equal(versionId, link.ChildId);
+
+ var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId));
+ Assert.Equal(primaryId, stored.PrimaryVersionId);
+
+ // Presentation-key grouping has to collapse it onto the primary as well.
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey);
+ }
+ }
+
+ [Fact]
+ public void SaveItems_LinkedAlternateVersionAlreadyAnItem_SetsPrimaryVersionId()
+ {
+ var primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ var versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ _service.SaveItems([CreateMovie(versionId, VersionPath)], CancellationToken.None);
+
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LinkedAlternateVersions =
+ [
+ new LinkedChild { ItemId = versionId, Type = LinkedChildType.LinkedAlternateVersion }
+ ];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ var link = Assert.Single(ctx.LinkedChildren.Where(e => e.ParentId.Equals(primaryId)));
+ Assert.Equal(DbLinkedChildType.LinkedAlternateVersion, link.ChildType);
+ Assert.Equal(primaryId, ctx.BaseItems.First(e => e.Id.Equals(versionId)).PrimaryVersionId);
+ }
+
+ [Fact]
+ public void SaveItems_VersionAlreadyPointingAtPrimary_LeavesItAlone()
+ {
+ var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
+ var versionId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
+
+ var version = CreateMovie(versionId, VersionPath);
+ version.SetPrimaryVersionId(primaryId);
+ _service.SaveItems([version], CancellationToken.None);
+
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LocalAlternateVersions = [VersionPath];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ var stored = ctx.BaseItems.First(e => e.Id.Equals(versionId));
+ Assert.Equal(primaryId, stored.PrimaryVersionId);
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), stored.PresentationUniqueKey);
+ }
+
+ [Fact]
+ public void SaveItems_VideoListedAmongItsOwnVersions_KeepsItsOwnPrimaryVersionId()
+ {
+ var primaryId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+
+ var primary = CreateMovie(primaryId, PrimaryPath);
+ primary.LocalAlternateVersions = [PrimaryPath];
+ _service.SaveItems([primary], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(primaryId)).PrimaryVersionId);
+ }
+
+ [Fact]
+ public void SaveItems_PromotedVersionStillPointingAtOldPrimary_DoesNotCreateACycle()
+ {
+ var promotedId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var oldPrimaryId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+
+ _service.SaveItems([CreateMovie(oldPrimaryId, VersionPath)], CancellationToken.None);
+
+ // The rescan resolves this one as the primary of the group, but it still carries the pointer
+ // to the version it was promoted over.
+ var promoted = CreateMovie(promotedId, PrimaryPath);
+ promoted.SetPrimaryVersionId(oldPrimaryId);
+ promoted.LocalAlternateVersions = [VersionPath];
+ _service.SaveItems([promoted], CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+
+ // Pointing the old primary back would hide both, and with them the whole group.
+ Assert.Null(ctx.BaseItems.First(e => e.Id.Equals(oldPrimaryId)).PrimaryVersionId);
+ Assert.Equal(oldPrimaryId, ctx.BaseItems.First(e => e.Id.Equals(promotedId)).PrimaryVersionId);
+ }
+
+ private static Movie CreateMovie(Guid id, string path) => new()
+ {
+ Id = id,
+ Name = "Movie",
+ Path = path
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs
new file mode 100644
index 0000000000..e2bdd9e0b2
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceDeleteItemTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// DeleteItem has to hand SQLite one statement that already contains everything the foreign keys
+/// on BaseItems require, because FK_BaseItems_BaseItems_OwnerId is NO ACTION: anything left behind
+/// pointing at a deleted row fails the whole delete with SQLite error 19.
+/// </summary>
+public sealed class ItemPersistenceDeleteItemTests : SqliteDbTestFixture
+{
+ private static readonly Guid _owner = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
+ private static readonly Guid _extra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000001");
+ private static readonly Guid _extraOfExtra = Guid.Parse("eeeeeeee-0000-0000-0000-000000000002");
+ private static readonly Guid _child = Guid.Parse("cccccccc-0000-0000-0000-000000000001");
+ private static readonly Guid _extraOfChild = Guid.Parse("eeeeeeee-0000-0000-0000-000000000003");
+
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistenceDeleteItemTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ new Mock<IServerApplicationHost>().Object,
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public void DeleteItem_OwnerIdChain_DeletesWholeChain()
+ {
+ // An extra that owns an extra of its own. Real libraries carry these in bulk, and a single
+ // expansion pass over OwnerId leaves the second level behind.
+ Seed(
+ (_owner, null, null),
+ (_extra, _owner, null),
+ (_extraOfExtra, _extra, null));
+
+ _service.DeleteItem([_owner]);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra) || e.Id.Equals(_extraOfExtra)));
+ }
+
+ [Fact]
+ public void DeleteItem_ExtraOwnedByCascadedChild_DeletesExtraToo()
+ {
+ // The child goes away through FK_BaseItems_BaseItems_ParentId's ON DELETE CASCADE whether or
+ // not it is listed, so an extra owned by that child has to be listed with it.
+ Seed(
+ (_owner, null, null),
+ (_child, null, _owner),
+ (_extraOfChild, _child, null));
+
+ _service.DeleteItem([_owner]);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_child) || e.Id.Equals(_extraOfChild)));
+ }
+
+ [Fact]
+ public void DeleteItem_OwnershipCycle_Terminates()
+ {
+ // A malformed pair that owns each other must not spin the closure loop forever.
+ Seed((_owner, null, null), (_extra, _owner, null));
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Single(e => e.Id.Equals(_owner)).OwnerId = _extra;
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([_owner]);
+
+ using var assertContext = CreateDbContext();
+ Assert.Empty(assertContext.BaseItems.Where(e => e.Id.Equals(_owner) || e.Id.Equals(_extra)));
+ }
+
+ private void Seed(params (Guid Id, Guid? OwnerId, Guid? ParentId)[] items)
+ {
+ using var context = CreateDbContext();
+
+ // Owners before the rows referencing them: the seed itself is foreign key checked.
+ foreach (var (id, ownerId, parentId) in items)
+ {
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = "MediaBrowser.Controller.Entities.Video",
+ OwnerId = ownerId,
+ ParentId = parentId
+ });
+
+ context.SaveChanges();
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs
new file mode 100644
index 0000000000..fc28025573
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistencePeopleCleanupTests.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Linq;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ItemPersistencePeopleCleanupTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistencePeopleCleanupTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ Mock.Of<IServerApplicationHost>(),
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public void DeleteItem_RemovesUnusedPeopleForItemsDescendantsAndExtras()
+ {
+ var parent = CreateItem(isFolder: true);
+ var child = CreateItem();
+ child.ParentId = parent.Id;
+ var extra = CreateItem();
+ extra.OwnerId = child.Id;
+ var survivor = CreateItem();
+ var shared = CreatePerson("Shared person");
+ var unrelatedOrphan = CreatePerson("Unrelated orphan");
+ using (var context = CreateDbContext())
+ {
+ context.PeopleBaseItemMap.AddRange(
+ Map(parent, CreatePerson("Parent credit")),
+ Map(child, CreatePerson("Child credit")),
+ Map(extra, CreatePerson("Extra credit")),
+ Map(child, shared),
+ Map(survivor, shared));
+ context.Peoples.Add(unrelatedOrphan);
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = child.Id,
+ Item = child,
+ ParentItemId = parent.Id,
+ ParentItem = parent
+ });
+ context.SaveChanges();
+ }
+
+ _service.DeleteItem([parent.Id]);
+
+ using var after = CreateDbContext();
+ Assert.Equal(survivor.Id, Assert.Single(after.BaseItems.Where(e => !e.Id.Equals(BaseItemRepository.PlaceholderId))).Id);
+ Assert.Equal(survivor.Id, Assert.Single(after.PeopleBaseItemMap).ItemId);
+ Assert.Equal(new[] { shared.Id, unrelatedOrphan.Id }.Order(), after.Peoples.Select(e => e.Id).Order());
+ }
+
+ private static BaseItemEntity CreateItem(bool isFolder = false) => new()
+ {
+ Id = Guid.NewGuid(),
+ Type = isFolder ? typeof(Folder).FullName! : typeof(Book).FullName!,
+ IsFolder = isFolder
+ };
+
+ private static People CreatePerson(string name) => new()
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ PersonType = "Actor"
+ };
+
+ private static PeopleBaseItemMap Map(BaseItemEntity item, People person) => new()
+ {
+ ItemId = item.Id,
+ Item = item,
+ PeopleId = person.Id,
+ People = person,
+ Role = string.Empty
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs
new file mode 100644
index 0000000000..7997c6d771
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public class ItemPersistenceServiceSaveImagesTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistenceServiceSaveImagesTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ Mock.Of<IServerApplicationHost>(),
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public async Task SaveImagesAsync_ReplacesThePreviousImages()
+ {
+ var itemId = Guid.NewGuid();
+ Seed(itemId);
+
+ await _service.SaveImagesAsync(CreateItem(itemId, "/first.jpg"), TestContext.Current.CancellationToken);
+ await _service.SaveImagesAsync(CreateItem(itemId, "/second.jpg"), TestContext.Current.CancellationToken);
+
+ using var context = CreateDbContext();
+ var paths = context.BaseItemImageInfos
+ .Where(e => e.ItemId.Equals(itemId))
+ .Select(e => e.Path)
+ .ToList();
+
+ Assert.Equal(["/second.jpg"], paths);
+ }
+
+ [Fact]
+ public async Task SaveImagesAsync_ItemDeletedFromUnderIt_IsANoOp()
+ {
+ // A scan can delete the item between the refresh reading it and the images being written. That
+ // must not fail the whole refresh, and must not leave the images of an item that is gone.
+ var itemId = Guid.NewGuid();
+
+ await _service.SaveImagesAsync(CreateItem(itemId, "/gone.jpg"), TestContext.Current.CancellationToken);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItemImageInfos.Where(e => e.ItemId.Equals(itemId)));
+ }
+
+ private static BaseItem CreateItem(Guid itemId, string imagePath)
+ => new Folder
+ {
+ Id = itemId,
+ ImageInfos = [new ItemImageInfo { Path = imagePath, Type = ImageType.Primary }]
+ };
+
+ private void Seed(Guid itemId)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = itemId,
+ Type = "Folder",
+ IsFolder = true
+ });
+ context.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs
new file mode 100644
index 0000000000..8ed3c61a59
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/NextUpServiceTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers Next Up over episodes with alternate versions: the episode that was watched is the one
+/// whose alternate carries the played row, so an episode already seen must not be offered again.
+/// </summary>
+public sealed class NextUpServiceTests : SqliteDbTestFixture
+{
+ private const string SeriesKey = "next-up-series";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+
+ private readonly NextUpService _service;
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+
+ private readonly Guid _playedViaAlternate = Guid.NewGuid();
+ private readonly Guid _unplayed = Guid.NewGuid();
+
+ public NextUpServiceTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+
+ using (var context = CreateDbContext())
+ {
+ Seed(context);
+ }
+
+ _service = new NextUpService(
+ CreateDbContextFactory(),
+ itemTypeLookup,
+ CreateBaseItemRepository(itemTypeLookup));
+ }
+
+ [Fact]
+ public void GetNextUpEpisodesBatch_EpisodePlayedThroughItsAlternateVersion_OffersTheOneAfterIt()
+ {
+ var batch = _service.GetNextUpEpisodesBatch(
+ new InternalItemsQuery(_user),
+ [SeriesKey],
+ includeSpecials: false,
+ includeWatchedForRewatching: false)[SeriesKey];
+
+ Assert.Equal(_playedViaAlternate, batch.LastWatched?.Id);
+ Assert.Equal(_unplayed, batch.NextUp?.Id);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.Users.Add(_user);
+
+ AddEpisode(context, _playedViaAlternate, 1);
+ AddEpisode(context, _unplayed, 2);
+
+ // The second file of the first episode, and the only row the playback was recorded against.
+ // It presents under its primary's key, which is what keeps it out of the candidate list.
+ var alternateId = Guid.NewGuid();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = alternateId,
+ Type = EpisodeType,
+ Name = "Episode 1 4K",
+ SeriesPresentationUniqueKey = SeriesKey,
+ ParentIndexNumber = 1,
+ IndexNumber = 1,
+ PresentationUniqueKey = _playedViaAlternate.ToString("N"),
+ PrimaryVersionId = _playedViaAlternate
+ });
+
+ context.SaveChanges();
+
+ // The link the scanner writes alongside PrimaryVersionId, and the hop the played state
+ // reaches the alternate through.
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = _playedViaAlternate,
+ ChildId = alternateId,
+ ChildType = LinkedChildType.LocalAlternateVersion,
+ SortOrder = 0
+ });
+
+ context.UserData.Add(new UserData
+ {
+ ItemId = alternateId,
+ UserId = _user.Id,
+ CustomDataKey = alternateId.ToString("N"),
+ Played = true,
+ Item = null!,
+ User = null!
+ });
+
+ context.SaveChanges();
+ }
+
+ private void AddEpisode(JellyfinDbContext context, Guid id, int indexNumber)
+ => context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = EpisodeType,
+ Name = $"Episode {indexNumber}",
+ SeriesPresentationUniqueKey = SeriesKey,
+ ParentIndexNumber = 1,
+ IndexNumber = indexNumber,
+ PresentationUniqueKey = id.ToString("N")
+ });
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs
new file mode 100644
index 0000000000..b925f98197
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleUpdateQueryTests.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Migrations.Operations;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class PeopleUpdateQueryTests : SqliteDbTestFixture
+{
+ private readonly CommandRecorder _recorder;
+ private readonly Guid _itemId = Guid.NewGuid();
+ private readonly PeopleRepository _people;
+
+ public PeopleUpdateQueryTests()
+ : this(new CommandRecorder())
+ {
+ }
+
+ private PeopleUpdateQueryTests(CommandRecorder recorder)
+ : base(recorder)
+ {
+ _recorder = recorder;
+ using var context = CreateDbContext();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _itemId,
+ Name = "Movie",
+ Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie]
+ });
+ context.SaveChanges();
+ _people = new PeopleRepository(CreateDbContextFactory(), new ItemTypeLookup(), Mock.Of<IItemQueryHelpers>());
+ }
+
+ [Theory]
+ [InlineData("Hero")]
+ [InlineData("HERO")]
+ public void UnchangedCredits_DoNotWriteOrLookUpAllPeople(string role)
+ {
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, Role = "Hero" }]);
+ _recorder.Commands.Clear();
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "actor", Type = PersonKind.Actor, Role = role }]);
+ Assert.Single(_recorder.Commands);
+ Assert.StartsWith("SELECT", _recorder.Commands[0].Sql, StringComparison.Ordinal);
+ using var context = CreateDbContext();
+ Assert.Equal("Hero", Assert.Single(context.PeopleBaseItemMap).Role);
+ }
+
+ [Fact]
+ public void SortOrderChange_IsPersisted()
+ {
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 1 }]);
+ _people.UpdatePeople(_itemId, [new PersonInfo { Name = "Actor", Type = PersonKind.Actor, SortOrder = 2 }]);
+ using var context = CreateDbContext();
+ Assert.Equal(2, Assert.Single(context.PeopleBaseItemMap).SortOrder);
+ }
+
+ [Fact]
+ public void UpdatePeople_GeneratedSqlUsesPeopleNameIndex()
+ {
+ ApplyMigration(new Jellyfin.Server.Implementations.Migrations.AddPeopleNameLowerIndex());
+ _recorder.Commands.Clear();
+ _people.UpdatePeople(_itemId, [
+ new PersonInfo { Name = "Actor A", Type = PersonKind.Actor },
+ new PersonInfo { Name = "Actor B", Type = PersonKind.Actor }
+ ]);
+ var query = Assert.Single(_recorder.Commands, c => c.Sql.Contains("lower(\"p\".\"Name\")", StringComparison.Ordinal));
+ Assert.Contains(Explain(query), line => line.Contains("SEARCH p USING INDEX IX_Peoples_NameLower", StringComparison.Ordinal));
+ }
+
+ private void ApplyMigration(Migration migration)
+ {
+ using var context = CreateDbContext();
+ foreach (var operation in migration.UpOperations.Cast<SqlOperation>())
+ {
+ context.Database.ExecuteSqlRaw(operation.Sql);
+ }
+ }
+
+ private string[] Explain(RecordedCommand query)
+ {
+ using var context = CreateDbContext();
+ using var command = context.Database.GetDbConnection().CreateCommand();
+#pragma warning disable CA2100 // query.Sql is generated by EF Core; query values remain bound parameters.
+ command.CommandText = "EXPLAIN QUERY PLAN " + query.Sql;
+#pragma warning restore CA2100
+ foreach (var value in query.Parameters)
+ {
+ var parameter = command.CreateParameter();
+ parameter.ParameterName = value.Name;
+ parameter.Value = value.Value;
+ command.Parameters.Add(parameter);
+ }
+
+ using var reader = command.ExecuteReader();
+ var plan = new List<string>();
+ while (reader.Read())
+ {
+ plan.Add(reader.GetString(3));
+ }
+
+ return plan.ToArray();
+ }
+
+ private sealed record RecordedCommand(string Sql, (string Name, object? Value)[] Parameters);
+
+ private sealed class CommandRecorder : DbCommandInterceptor
+ {
+ public List<RecordedCommand> Commands { get; } = [];
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ Record(command);
+ return result;
+ }
+
+ private void Record(DbCommand command) => Commands.Add(new RecordedCommand(
+ command.CommandText,
+ command.Parameters.Cast<DbParameter>().Select(p => (p.ParameterName, p.Value)).ToArray()));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
index 87efa8fea5..6da176b4f1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
@@ -1,4 +1,5 @@
using System;
+using System.Threading;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
@@ -10,6 +11,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -25,7 +27,7 @@ public abstract class SqliteDbTestFixture : IDisposable
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
- protected SqliteDbTestFixture()
+ protected SqliteDbTestFixture(params IInterceptor[] interceptors)
{
ApplicationPaths = new Mock<IApplicationPaths>().Object;
@@ -34,6 +36,7 @@ public abstract class SqliteDbTestFixture : IDisposable
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
+ .AddInterceptors(interceptors)
.Options;
using var context = CreateDbContext();
@@ -58,6 +61,8 @@ public abstract class SqliteDbTestFixture : IDisposable
{
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
return factory.Object;
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs
new file mode 100644
index 0000000000..31109b2968
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/ResolveAlternateVersionTests.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Model.IO;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library.LibraryManager;
+
+public sealed class ResolveAlternateVersionTests : IDisposable
+{
+ private const string PrimaryPath = "/movies/Up/Up.mkv";
+ private const string AlternatePath = "/movies/Up/Up - 1080p.mkv";
+
+ private readonly Emby.Server.Implementations.Library.LibraryManager _libraryManager;
+ private readonly Mock<IItemPersistenceService> _persistenceServiceMock;
+ private readonly Folder _staleParent;
+ private readonly ILibraryManager? _previousLibraryManager;
+ private readonly IMediaSourceManager? _previousMediaSourceManager;
+ private readonly IItemRepository? _previousItemRepository;
+
+ public ResolveAlternateVersionTests()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ fixture.Freeze<Mock<IServerConfigurationManager>>()
+ .Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ _persistenceServiceMock = fixture.Freeze<Mock<IItemPersistenceService>>();
+ var itemRepositoryMock = fixture.Freeze<Mock<IItemRepository>>();
+ fixture.Freeze<Mock<IFileSystem>>()
+ .Setup(f => f.GetFileInfo(It.IsAny<string>()))
+ .Returns<string>(path => new FileSystemMetadata { FullName = path });
+
+ _libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>()
+ .Do(s => s.AddParts(
+ fixture.Create<IEnumerable<IResolverIgnoreRule>>(),
+ [],
+ fixture.Create<IEnumerable<IIntroProvider>>(),
+ fixture.Create<IEnumerable<IBaseItemComparer>>(),
+ fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
+ .Create();
+
+ // BaseItem resolves these through process-wide statics; restored in Dispose.
+ _previousLibraryManager = BaseItem.LibraryManager;
+ _previousMediaSourceManager = BaseItem.MediaSourceManager;
+ _previousItemRepository = BaseItem.ItemRepository;
+ BaseItem.LibraryManager = _libraryManager;
+
+ var mediaSourceManagerMock = new Mock<IMediaSourceManager>();
+ mediaSourceManagerMock.Setup(m => m.GetMediaStreams(It.IsAny<Guid>())).Returns([]);
+ mediaSourceManagerMock.Setup(m => m.GetMediaAttachments(It.IsAny<Guid>())).Returns([]);
+ BaseItem.MediaSourceManager = mediaSourceManagerMock.Object;
+
+ // A reloaded listing comes back empty, so a stale entry surviving is visible.
+ itemRepositoryMock.Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>())).Returns([]);
+ BaseItem.ItemRepository = itemRepositoryMock.Object;
+
+ BaseItem.FileSystem ??= fixture.Create<IFileSystem>();
+ BaseItem.MediaSegmentManager ??= fixture.Create<IMediaSegmentManager>();
+ BaseItem.ConfigurationManager ??= fixture.Create<IServerConfigurationManager>();
+ Video.RecordingsManager ??= fixture.Create<IRecordingsManager>();
+
+ var primary = new Movie
+ {
+ Name = "Up",
+ Path = PrimaryPath,
+ LocalAlternateVersions = [AlternatePath],
+ Id = _libraryManager.GetNewItemId(PrimaryPath, typeof(Movie))
+ };
+
+ _staleParent = new Folder
+ {
+ Name = "Up",
+ Path = "/movies/Up",
+ Id = _libraryManager.GetNewItemId("/movies/Up", typeof(Folder))
+ };
+
+ var staleAlternate = new Video
+ {
+ Name = "Up - 1080p",
+ Path = AlternatePath,
+ OwnerId = primary.Id,
+ ParentId = _staleParent.Id,
+ Id = _libraryManager.GetNewItemId(AlternatePath, typeof(Video))
+ };
+ staleAlternate.SetPrimaryVersionId(primary.Id);
+
+ itemRepositoryMock
+ .Setup(i => i.RetrieveItem(It.IsAny<Guid>()))
+ .Returns<Guid>(id => id.Equals(primary.Id) ? primary
+ : id.Equals(staleAlternate.Id) ? staleAlternate
+ : id.Equals(_staleParent.Id) ? _staleParent
+ : null!);
+
+ StaleAlternateId = staleAlternate.Id;
+ }
+
+ private Guid StaleAlternateId { get; }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ BaseItem.MediaSourceManager = _previousMediaSourceManager!;
+ BaseItem.ItemRepository = _previousItemRepository!;
+ }
+
+ [Fact]
+ public void ResolveAlternateVersion_StaleWrongTypeItem_DropsRowWithoutResavingPrimary()
+ {
+ // The alternate is stored under the id of the generic Video type while its primary is a Movie.
+ _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null);
+
+ _persistenceServiceMock.Verify(
+ p => p.DeleteItem(It.Is<IReadOnlyList<Guid>>(ids => ids.Count == 1 && ids[0].Equals(StaleAlternateId))),
+ Times.Once);
+
+ // Saving the primary is what re-enters this method before the stale row is gone.
+ _persistenceServiceMock.Verify(
+ p => p.SaveItems(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<CancellationToken>()),
+ Times.Never);
+ }
+
+ [Fact]
+ public void ResolveAlternateVersion_StaleWrongTypeItem_DropsCachedParentListing()
+ {
+ _staleParent.Children = [new Video { Name = "Up - 1080p", Path = AlternatePath }];
+
+ _libraryManager.ResolveAlternateVersion(AlternatePath, typeof(Movie), null, null);
+
+ Assert.Empty(_staleParent.Children);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs
new file mode 100644
index 0000000000..6d0c491382
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManagerScanTests.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AutoFixture;
+using AutoFixture.AutoMoq;
+using Emby.Naming.Common;
+using Emby.Server.Implementations.ScheduledTasks.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Tasks;
+using Moq;
+using Xunit;
+using ServerLibraryManager = Emby.Server.Implementations.Library.LibraryManager;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public class LibraryManagerScanTests
+{
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task StartScanInBackground_QueuesOnlyWhenIdle(bool scanRunning)
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>();
+ configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ var tasks = fixture.Freeze<Mock<ITaskManager>>();
+ var manager = fixture.Create<ServerLibraryManager>();
+ typeof(ServerLibraryManager).GetProperty(nameof(ServerLibraryManager.IsScanRunning))!.SetValue(manager, scanRunning);
+
+ await manager.StartScanInBackground().ConfigureAwait(true);
+
+ tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), scanRunning ? Times.Never() : Times.Once());
+ tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Never());
+ }
+
+ [Fact]
+ public async Task ValidateMediaLibrary_RestartsScheduledScan()
+ {
+ var fixture = new Fixture().Customize(new AutoMoqCustomization());
+ fixture.Register(() => new NamingOptions());
+ var configuration = fixture.Freeze<Mock<IServerConfigurationManager>>();
+ configuration.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ configuration.Setup(c => c.ApplicationPaths.ProgramDataPath).Returns("/data");
+ var tasks = fixture.Freeze<Mock<ITaskManager>>();
+ var manager = fixture.Create<ServerLibraryManager>();
+
+ await manager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(true);
+
+ tasks.Verify(t => t.CancelIfRunningAndQueue<RefreshMediaLibraryTask>(), Times.Once());
+ tasks.Verify(t => t.QueueScheduledTask<RefreshMediaLibraryTask>(), Times.Never());
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index c80f899498..131cb23fa4 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -7,7 +7,9 @@ using Castle.Components.DictionaryAdapter;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
@@ -149,6 +151,73 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex);
}
+ [Theory]
+ // A remembered full track must not survive a switch to "only forced" (it falls through to
+ // the forced track here); a remembered forced track and "off" still must.
+ [InlineData(SubtitlePlaybackMode.OnlyForced, 2, 3)]
+ [InlineData(SubtitlePlaybackMode.OnlyForced, 3, 3)]
+ [InlineData(SubtitlePlaybackMode.OnlyForced, -1, -1)]
+ [InlineData(SubtitlePlaybackMode.Default, 2, 2)]
+ [InlineData(SubtitlePlaybackMode.Always, 2, 2)]
+ [InlineData(SubtitlePlaybackMode.Smart, 2, 2)]
+ [InlineData(SubtitlePlaybackMode.None, 2, null)]
+ public void SetDefaultSubtitleStreamIndex_RememberedSelection_RespectsSubtitleMode(
+ SubtitlePlaybackMode mode,
+ int rememberedIndex,
+ int? expectedIndex)
+ {
+ _mockUserDataManager
+ .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
+ .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = rememberedIndex });
+
+ var mediaInfo = new MediaSourceInfo
+ {
+ MediaStreams = new MediaStream[]
+ {
+ new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true },
+ new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true },
+ new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false },
+ new() { Index = 3, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = false, IsForced = true }
+ }
+ };
+
+ _user.SubtitleMode = mode;
+ _user.SubtitleLanguagePreference = string.Empty;
+ _user.RememberSubtitleSelections = true;
+ _user.AudioLanguagePreference = string.Empty;
+
+ _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
+
+ Assert.Equal(expectedIndex, mediaInfo.DefaultSubtitleStreamIndex);
+ }
+
+ [Fact]
+ public void SetDefaultSubtitleStreamIndex_OnlyForcedRemembersFullTrackWithNoForcedStream_SelectsNothing()
+ {
+ _mockUserDataManager
+ .Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
+ .Returns(new UserItemData { Key = "key", SubtitleStreamIndex = 2 });
+
+ var mediaInfo = new MediaSourceInfo
+ {
+ MediaStreams = new MediaStream[]
+ {
+ new() { Index = 0, Type = MediaStreamType.Video, IsDefault = true },
+ new() { Index = 1, Type = MediaStreamType.Audio, Language = "eng", IsDefault = true },
+ new() { Index = 2, Type = MediaStreamType.Subtitle, Language = "eng", IsDefault = true, IsForced = false }
+ }
+ };
+
+ _user.SubtitleMode = SubtitlePlaybackMode.OnlyForced;
+ _user.SubtitleLanguagePreference = string.Empty;
+ _user.RememberSubtitleSelections = true;
+ _user.AudioLanguagePreference = string.Empty;
+
+ _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
+
+ Assert.Null(mediaInfo.DefaultSubtitleStreamIndex);
+ }
+
[Fact]
public void GetStaticMediaSources_PrimaryQueried_DefaultsToMostRecentlyPlayedVersion()
{
@@ -195,6 +264,14 @@ namespace Jellyfin.Server.Implementations.Tests.Library
}
[Fact]
+ public void GetStaticMediaSources_ItemWithoutMediaSources_ThrowsArgumentException()
+ {
+ // A container queued by mistake is a bad request, not a server fault.
+ Assert.Throws<ArgumentException>(
+ () => _mediaSourceManager.GetStaticMediaSources(new MusicArtist { Id = Guid.NewGuid() }, false, _user));
+ }
+
+ [Fact]
public void GetStaticMediaSources_NoUser_DoesNotTouchUserData()
{
var (primary, _, _) = SetupVersionGroup();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs
new file mode 100644
index 0000000000..421671b520
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MovieSimilarItemsProviderTests.cs
@@ -0,0 +1,247 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Emby.Server.Implementations.Library.SimilarItems;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Tests.Item;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+/// <summary>
+/// Covers how <see cref="MovieSimilarItemsProvider"/> treats alternate versions: they share their
+/// primary's genres, tags, studios and people, so they score like it and must not be offered as
+/// something similar - neither as another copy of a recommendation nor as a match for the source.
+/// </summary>
+public sealed class MovieSimilarItemsProviderTests : SqliteDbTestFixture
+{
+ private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ private readonly MovieSimilarItemsProvider _provider;
+ private readonly Mock<ILibraryManager> _libraryManager = new();
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+ private readonly string _movieTypeName;
+ private readonly string _folderTypeName;
+
+ private readonly Guid _source = Guid.NewGuid();
+ private readonly Guid _sourceAlternate = Guid.NewGuid();
+ private readonly Guid _similar = Guid.NewGuid();
+ private readonly Guid _similarAlternate = Guid.NewGuid();
+ private readonly Guid _unrelated = Guid.NewGuid();
+
+ // A second scenario, in two libraries and on a genre of its own, for the group whose primary the
+ // user may not be able to reach at all.
+ private readonly Guid _crossSource = Guid.NewGuid();
+ private readonly Guid _crossLibraryPrimary = Guid.NewGuid();
+ private readonly Guid _crossLibraryVersion = Guid.NewGuid();
+ private readonly Guid _sameLibraryPrimary = Guid.NewGuid();
+ private readonly Guid _sameLibraryVersion = Guid.NewGuid();
+
+ public MovieSimilarItemsProviderTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!;
+ _folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!;
+
+ using (var context = CreateDbContext())
+ {
+ Seed(context);
+ }
+
+ var serverConfigurationManager = new Mock<IServerConfigurationManager>();
+ serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+
+ _provider = new MovieSimilarItemsProvider(
+ CreateDbContextFactory(),
+ CreateBaseItemRepository(itemTypeLookup),
+ serverConfigurationManager.Object,
+ _libraryManager.Object);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_ReturnsThePrimaryAndNeitherVersionOfTheSource()
+ {
+ var items = await GetSimilarItemsAsync().ConfigureAwait(true);
+
+ Assert.Equal([_similar], items);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_DoesNotOfferAnAlternateVersionOfAMatch()
+ {
+ var items = await GetSimilarItemsAsync().ConfigureAwait(true);
+
+ Assert.DoesNotContain(_similarAlternate, items);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_DoesNotOfferTheSourcesOwnOtherVersion()
+ {
+ var items = await GetSimilarItemsAsync().ConfigureAwait(true);
+
+ Assert.DoesNotContain(_sourceAlternate, items);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_UserWithoutThePrimarysLibrary_OffersTheVersion()
+ {
+ // The user may only open the library the 1080p version is in, so its primary is out of reach
+ // and the version is all that is left to stand in for the group.
+ RestrictUserTo(_movieLibraryId);
+
+ var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true);
+
+ Assert.Contains(_crossLibraryVersion, items);
+ Assert.DoesNotContain(_crossLibraryPrimary, items);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_UserWithBothLibraries_OffersThePrimaryOfTheGroupOnce()
+ {
+ RestrictUserTo(_movieLibraryId, _movie4KLibraryId);
+
+ var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true);
+
+ Assert.Contains(_crossLibraryPrimary, items);
+ Assert.DoesNotContain(_crossLibraryVersion, items);
+ }
+
+ [Fact]
+ public async Task GetSimilarItems_GroupMergedInsideOneLibrary_StillOffersOnlyThePrimary()
+ {
+ RestrictUserTo(_movieLibraryId, _movie4KLibraryId);
+
+ var items = await GetSimilarItemsAsync(_crossSource).ConfigureAwait(true);
+
+ Assert.Contains(_sameLibraryPrimary, items);
+ Assert.DoesNotContain(_sameLibraryVersion, items);
+ }
+
+ private void RestrictUserTo(params Guid[] libraryIds)
+ {
+ _libraryManager
+ .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>()))
+ .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds);
+ }
+
+ private async Task<List<Guid>> GetSimilarItemsAsync(Guid? sourceId = null)
+ {
+ var results = await _provider.GetSimilarItemsAsync(
+ new Movie { Id = sourceId ?? _source, Name = "Source" },
+ new SimilarItemsQuery { User = _user, Limit = 10, DtoOptions = new DtoOptions() },
+ CancellationToken.None).ConfigureAwait(false);
+
+ return results.Select(i => i.Id).ToList();
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ // One shared genre, so every movie but the unrelated one scores against the source.
+ var shared = CreateItemValue("Action", "action");
+ var other = CreateItemValue("Comedy", "comedy");
+
+ var source = AddMovie(context, _source, "Source", primaryVersionId: null);
+ var sourceAlternate = AddMovie(context, _sourceAlternate, "Source 4K", primaryVersionId: _source);
+ var similar = AddMovie(context, _similar, "Similar", primaryVersionId: null);
+ var similarAlternate = AddMovie(context, _similarAlternate, "Similar 4K", primaryVersionId: _similar);
+ var unrelated = AddMovie(context, _unrelated, "Unrelated", primaryVersionId: null);
+
+ // The second scenario scores on a genre of its own, so it stays out of the results above.
+ var crossLibrary = CreateItemValue("Science Fiction", "science fiction");
+
+ AddLibrary(context, _movieLibraryId, "Movies");
+ AddLibrary(context, _movie4KLibraryId, "Movies-4K");
+
+ var crossSource = AddMovie(context, _crossSource, "Cross Source", primaryVersionId: null, libraryId: _movieLibraryId);
+
+ // The 4K version heads the group and lives in a library of its own.
+ var crossLibraryPrimary = AddMovie(context, _crossLibraryPrimary, "Coco 4K", primaryVersionId: null, libraryId: _movie4KLibraryId);
+ var crossLibraryVersion = AddMovie(context, _crossLibraryVersion, "Coco", primaryVersionId: _crossLibraryPrimary, libraryId: _movieLibraryId);
+
+ // A group merged inside one library, as a control.
+ var sameLibraryPrimary = AddMovie(context, _sameLibraryPrimary, "Up 4K", primaryVersionId: null, libraryId: _movieLibraryId);
+ var sameLibraryVersion = AddMovie(context, _sameLibraryVersion, "Up", primaryVersionId: _sameLibraryPrimary, libraryId: _movieLibraryId);
+
+ context.Users.Add(_user);
+ context.ItemValues.AddRange(shared, other, crossLibrary);
+ context.ItemValuesMap.AddRange(
+ CreateMap(source, shared),
+ CreateMap(sourceAlternate, shared),
+ CreateMap(similar, shared),
+ CreateMap(similarAlternate, shared),
+ CreateMap(unrelated, other),
+ CreateMap(crossSource, crossLibrary),
+ CreateMap(crossLibraryPrimary, crossLibrary),
+ CreateMap(crossLibraryVersion, crossLibrary),
+ CreateMap(sameLibraryPrimary, crossLibrary),
+ CreateMap(sameLibraryVersion, crossLibrary));
+
+ context.SaveChanges();
+ }
+
+ private void AddLibrary(JellyfinDbContext context, Guid id, string name)
+ {
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = _folderTypeName,
+ Name = name,
+ Path = "/" + name,
+ IsFolder = true
+ });
+ }
+
+ private BaseItemEntity AddMovie(JellyfinDbContext context, Guid id, string name, Guid? primaryVersionId, Guid? libraryId = null)
+ {
+ var item = new BaseItemEntity
+ {
+ Id = id,
+ Type = _movieTypeName,
+ Name = name,
+ SortName = name,
+ ParentId = libraryId,
+ TopParentId = libraryId,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false,
+ // An alternate presents under its primary's key, which is what collapses the group in listings.
+ PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"),
+ PrimaryVersionId = primaryVersionId
+ };
+
+ context.BaseItems.Add(item);
+ return item;
+ }
+
+ private static ItemValue CreateItemValue(string value, string cleanValue)
+ => new()
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = value,
+ CleanValue = cleanValue
+ };
+
+ private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue)
+ => new()
+ {
+ ItemId = item.Id,
+ ItemValueId = itemValue.ItemValueId,
+ Item = item,
+ ItemValue = itemValue
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
new file mode 100644
index 0000000000..30f7bed208
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library.Validators;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+/// <summary>
+/// Tests for how the people validator decides which credits need a person item and which person items
+/// nothing credits any more. Keying either half on the item's name rather than its id put the two halves
+/// in a loop that created, refreshed and deleted the same people on every run, so these pin the id.
+/// </summary>
+public class PeopleValidatorPartitionTests
+{
+ // Stands in for the real item-by-name id: derived from the credit name, case-insensitively, and
+ // from nothing else. The property that matters is that it does not depend on the item's own name.
+ private static Guid PersonId(string creditName)
+ {
+#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
+ var hash = System.Security.Cryptography.MD5.HashData(
+ System.Text.Encoding.Unicode.GetBytes(creditName.ToLowerInvariant()));
+#pragma warning restore CA5351 // Do Not Use Broken Cryptographic Algorithms
+ return new Guid(hash);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ProviderRenamedThePerson_KeepsThemAndCreatesNothing()
+ {
+ // The credit still says "AURORA"; the item it made has been renamed to "Aurora" by the provider
+ // that refreshed it. Nothing about the library changed, so nothing should be created or deleted.
+ var credits = new[] { "AURORA" };
+ var existing = new HashSet<Guid> { PersonId("AURORA") };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Theory]
+ // Every shape of rename seen in the wild on a real library.
+ [InlineData("AURORA")]
+ [InlineData("Amir AboulEla")]
+ [InlineData("Miguel Ángel Fuentes")]
+ [InlineData("a‐ha")]
+ [InlineData("윤현민")]
+ public void PartitionCreditsByPersonId_CreditWithAnItem_IsNeverBothCreatedAndDeleted(string creditName)
+ {
+ var existing = new HashSet<Guid> { PersonId(creditName) };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId([creditName], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditWithNoItem_IsCreated()
+ {
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["Wanted Person"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Equal(["Wanted Person"], newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ItemNoCreditNames_IsDead()
+ {
+ var orphan = PersonId("Nobody Credits Me");
+ var existing = new HashSet<Guid> { PersonId("Credited"), orphan };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(["Credited"], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Equal([orphan], deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditsNormalizingOntoOneId_CreateOneItem()
+ {
+ // "AURORA" and "Aurora" are one person as far as the item-by-name id is concerned, so exactly
+ // one of them should create the item and neither should end up dead.
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["AURORA", "Aurora", "aurora"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Single(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_SecondRunAfterCreating_AsksForNothingFurther()
+ {
+ // The churn showed up as a run that never settled, so drive two rounds: whatever round one
+ // created must leave round two with nothing to do.
+ string[] credits = ["AURORA", "Amir AboulEla", "Miguel Ángel Fuentes"];
+ var existing = new HashSet<Guid>();
+
+ var (firstNames, firstDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+ Assert.Equal(3, firstNames.Count);
+ Assert.Empty(firstDead);
+
+ foreach (var created in firstNames)
+ {
+ existing.Add(PersonId(created));
+ }
+
+ var (secondNames, secondDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(secondNames);
+ Assert.Empty(secondDead);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs
new file mode 100644
index 0000000000..5aa770b9b3
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SqlSearchProviderTests.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Emby.Server.Implementations.Library.Search;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using Jellyfin.Server.Implementations.Tests.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+/// <summary>
+/// Covers what <see cref="SqlSearchProvider"/> returns for a version group merged across two
+/// libraries: the primary represents the group wherever it is visible, and the version stands in
+/// for it for a user who cannot open the library the primary lives in.
+/// </summary>
+public sealed class SqlSearchProviderTests : SqliteDbTestFixture
+{
+ private static readonly Guid _movieLibraryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ private static readonly Guid _movie4KLibraryId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ private static readonly Guid _primaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ private static readonly Guid _versionId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ private readonly SqlSearchProvider _provider;
+ private readonly Mock<ILibraryManager> _libraryManager = new();
+ private readonly User _user = new("test", "auth-provider", "reset-provider");
+
+ public SqlSearchProviderTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ var movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]!;
+ var folderTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Folder]!;
+
+ using (var context = CreateDbContext())
+ {
+ context.Users.Add(_user);
+ context.BaseItems.Add(CreateLibrary(_movieLibraryId, folderTypeName, "Movies", "/movies"));
+ context.BaseItems.Add(CreateLibrary(_movie4KLibraryId, folderTypeName, "Movies-4K", "/movies-4k"));
+ context.BaseItems.Add(CreateMovie(_primaryId, movieTypeName, _movie4KLibraryId, null));
+ context.BaseItems.Add(CreateMovie(_versionId, movieTypeName, _movieLibraryId, _primaryId));
+ context.SaveChanges();
+ }
+
+ var userManager = new Mock<IUserManager>();
+ userManager.Setup(u => u.GetUserById(_user.Id)).Returns(_user);
+
+ _provider = new SqlSearchProvider(
+ CreateDbContextFactory(),
+ itemTypeLookup,
+ _libraryManager.Object,
+ userManager.Object,
+ CreateBaseItemRepository(itemTypeLookup));
+ }
+
+ [Fact]
+ public async Task SearchAsync_UserWithoutThePrimarysLibrary_FindsTheVersion()
+ {
+ RestrictUserTo(_movieLibraryId);
+
+ var hits = await SearchAsync().ConfigureAwait(true);
+
+ Assert.Equal([_versionId], hits);
+ }
+
+ [Fact]
+ public async Task SearchAsync_UserWithBothLibraries_FindsThePrimaryOnce()
+ {
+ RestrictUserTo(_movieLibraryId, _movie4KLibraryId);
+
+ var hits = await SearchAsync().ConfigureAwait(true);
+
+ Assert.Equal([_primaryId], hits);
+ }
+
+ private void RestrictUserTo(params Guid[] libraryIds)
+ {
+ _libraryManager
+ .Setup(l => l.ConfigureUserAccess(It.IsAny<InternalItemsQuery>(), It.IsAny<User>()))
+ .Callback<InternalItemsQuery, User>((query, _) => query.TopParentIds = libraryIds);
+ }
+
+ private async Task<List<Guid>> SearchAsync()
+ {
+ var results = await _provider.SearchAsync(
+ new SearchProviderQuery { SearchTerm = "coco", UserId = _user.Id, Limit = 10 },
+ CancellationToken.None).ConfigureAwait(false);
+
+ return results.Select(r => r.ItemId).ToList();
+ }
+
+ private static BaseItemEntity CreateLibrary(Guid id, string typeName, string name, string path)
+ => new()
+ {
+ Id = id,
+ Type = typeName,
+ Name = name,
+ Path = path,
+ IsFolder = true
+ };
+
+ private static BaseItemEntity CreateMovie(Guid id, string typeName, Guid libraryId, Guid? primaryVersionId)
+ => new()
+ {
+ Id = id,
+ Type = typeName,
+ Name = "Coco",
+ CleanName = "coco",
+ SortName = "Coco",
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false,
+ ParentId = libraryId,
+ TopParentId = libraryId,
+ PresentationUniqueKey = (primaryVersionId ?? id).ToString("N"),
+ PrimaryVersionId = primaryVersionId
+ };
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index d973076ed3..93014e7244 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -199,6 +199,16 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
[InlineData("Rated: R", "US", 17, 0)]
[InlineData("Rated R", "US", 17, 0)]
[InlineData(" PG-13 ", "US", 13, 0)]
+ [InlineData("T", "IT", 0, null)]
+ [InlineData("VM6", "IT", 6, null)]
+ [InlineData("VM12", "IT", 12, null)]
+ [InlineData("VM14", "IT", 14, null)]
+ [InlineData("VM18", "IT", 18, null)]
+ [InlineData("IT-VM14", "IT", 14, null)] // TMDB style country prefix
+ [InlineData("IT-VM18", "IT", 18, null)]
+ [InlineData("it-vm18", "IT", 18, null)] // Rating strings are case insensitive
+ [InlineData("VM 18", "IT", 18, null)]
+ [InlineData("Vietato ai minori di 18 anni", "IT", 18, null)]
public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore)
{
var localizationManager = Setup(new ServerConfiguration()
@@ -213,6 +223,30 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Theory]
+ // Rating strings are stored mixed-case in the *.json rating systems and must match regardless of casing
+ [InlineData("btl", "se", 0, null)] // Direct lookup, lowercase of "Btl"
+ [InlineData("BARNTILLÅTEN", "se", 0, null)] // Direct lookup, uppercase incl. diacritics
+ [InlineData("SE-BTL", "se", 0, null)] // Country prefix stripped against the configured country
+ [InlineData("SE-BTL", "us", 0, null)] // Country prefix resolved via the separator fallback
+ [InlineData("Från 7 År", "se", 7, null)] // Diacritic casing (json has "Från 7 år")
+ [InlineData("SE-Från 7 År", "us", 7, null)] // Same, via the separator fallback
+ [InlineData("fsk-16", "de", 16, null)] // Not Sweden specific: lowercase of "FSK-16"
+ public async Task GetRatingScore_IsCaseInsensitive_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ MetadataCountryCode = countryCode
+ });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(value);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Theory]
[InlineData("0", 0, null)]
[InlineData("1", 1, null)]
[InlineData("6", 6, null)]
@@ -241,6 +275,51 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Null(localizationManager.GetRatingScore("unrated"));
Assert.Null(localizationManager.GetRatingScore("Not Rated"));
Assert.Null(localizationManager.GetRatingScore("n/a"));
+ Assert.Null(localizationManager.GetRatingScore("N/A"));
+ Assert.Null(localizationManager.GetRatingScore(" n/a "));
+ }
+
+ [Theory]
+ // "NR" and "UR" are rating strings of some systems, so they must stay unrated when listed alongside others
+ [InlineData("NR / R", 17, 0)]
+ [InlineData("unrated / R", 17, 0)]
+ [InlineData("R / NR", 17, 0)]
+ public async Task GetRatingLevel_SkipsUnratedListEntries_Success(string value, int? expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "us" });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(value);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Theory]
+ // Ratings that contain a '/' themselves must not be split into a list of ratings
+ [InlineData("M/3", "pt", 3, null)]
+ [InlineData("M/12", "pt", 12, null)]
+ [InlineData("M/18", "pt", 18, null)]
+ [InlineData("PT-M/12", "pt", 12, null)] // TMDB style country prefix
+ [InlineData("M/12", "us", 12, null)] // Resolved through the all-systems fallback
+ [InlineData("U/A 13+", "in", 13, null)]
+ [InlineData("7/i", "es", 11, null)]
+ [InlineData("7/i/fig", "es", 11, null)]
+ [InlineData("18/fig", "es", 18, null)]
+ public async Task GetRatingScore_RatingContainingSlash_IsNotSplit(string value, string countryCode, int expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ MetadataCountryCode = countryCode
+ });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(value);
+
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
}
[Theory]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
index 265b6a7f43..ee41b968e1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
@@ -240,6 +240,28 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
}
[Fact]
+ public async Task PopulateManifest_ExistingImage_IsNotDownloaded()
+ {
+ const string ImageContent = "not really a png";
+
+ var packageInfo = GenerateTestPackage();
+ packageInfo.ImageUrl = "https://example.org/some-plugin.png";
+
+ var imagePath = Path.Combine(_pluginPath, "some-plugin.png");
+ await File.WriteAllTextAsync(imagePath, ImageContent, TestContext.Current.CancellationToken);
+
+ // The application host is null, so attempting to download the image would throw.
+ var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, null!, new Version(1, 0));
+
+ Assert.True(await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active));
+
+ var result = pluginManager.LoadManifest(_pluginPath).Manifest;
+
+ Assert.Equal(imagePath, result.ImagePath);
+ Assert.Equal(ImageContent, await File.ReadAllTextAsync(imagePath, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
public async Task PopulateManifest_ExistingMetafileMismatchedIds_Status_Malfunctioned()
{
var packageInfo = GenerateTestPackage();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
new file mode 100644
index 0000000000..a07e79baa3
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/PlayCommandQueueTests.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Events;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.SessionManager;
+
+public class PlayCommandQueueTests : IDisposable
+{
+ private readonly ILibraryManager? _previousLibraryManager;
+
+ public PlayCommandQueueTests()
+ {
+ _previousLibraryManager = BaseItem.LibraryManager;
+ }
+
+ /// <summary>
+ /// A music genre tags its artists as well as their songs, and a by-name artist row is not a
+ /// folder, so the queue query cannot exclude it. Such an item has no media sources, and a
+ /// client that reaches it in the queue gets an error instead of the next track.
+ /// </summary>
+ /// <returns><placeholder>A <see cref="Task"/> representing the asynchronous unit test.</placeholder></returns>
+ [Fact]
+ public async Task SendPlayCommand_GenreTaggingAnArtist_QueuesOnlyPlayableItems()
+ {
+ var genre = new MusicGenre { Id = Guid.NewGuid(), Name = "Reggaeton" };
+ var song = new Audio { Id = Guid.NewGuid(), Name = "Me Porto Bonito" };
+ var artist = new MusicArtist { Id = Guid.NewGuid(), Name = "NATTI NATASHA" };
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(i => i.GetItemById(genre.Id)).Returns(genre);
+ libraryManager
+ .Setup(i => i.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(new List<BaseItem> { artist, song });
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ await using var sessionManager = new Emby.Server.Implementations.Session.SessionManager(
+ NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
+ Mock.Of<IEventManager>(),
+ Mock.Of<IUserDataManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ libraryManager.Object,
+ Mock.Of<IUserManager>(),
+ Mock.Of<IMusicManager>(),
+ Mock.Of<IDtoService>(),
+ Mock.Of<IImageProcessor>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<IDeviceManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IHostApplicationLifetime>());
+
+ var session = await sessionManager.LogSessionActivity("app_name", "0.0.0", "device_id", "device_name", "127.0.0.1", null);
+
+ var command = new PlayRequest
+ {
+ ItemIds = new[] { genre.Id },
+ PlayCommand = PlayCommand.PlayNow
+ };
+
+ await sessionManager.SendPlayCommand(null, session.Id, command, CancellationToken.None);
+
+ Assert.Equal(new[] { song.Id }, command.ItemIds);
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
index 32685556b2..05e8a40de1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
@@ -143,6 +143,36 @@ public class PlayQueueManagerTests
}
[Fact]
+ public void SetShuffleMode_SortedWhileAlreadySorted_KeepsPlayingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(1);
+ var expectedItemId = queue.GetPlayingItemId();
+
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+
+ Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void SetShuffleMode_SortedTwiceAfterShuffle_KeepsPlayingItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetPlayingItemByIndex(2);
+ var expectedItemId = queue.GetPlayingItemId();
+
+ queue.SetShuffleMode(GroupShuffleMode.Shuffle);
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+
+ Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
+ Assert.Equal(5, queue.GetPlaylist().Count);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
public void SetPlayingItemByIndex_InBounds_SetsPlayingItem()
{
var queue = CreateQueue(2);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
new file mode 100644
index 0000000000..ecd8fafe80
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Controller.SyncPlay.Requests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group;
+using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class SyncPlayManagerTests
+{
+ [Fact]
+ public void LeaveGroup_AfterJoiningTheSameGroupTwice_ClearsTheActiveSessionCounter()
+ {
+ var harness = new ManagerHarness();
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ Assert.True(harness.Manager.IsUserActive(harness.User.Id));
+
+ // A client that re-sends Join for the group it is already in must not be counted twice.
+ harness.Manager.JoinGroup(harness.Session, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ [Fact]
+ public void LeaveGroup_AfterASingleJoin_ClearsTheActiveSessionCounter()
+ {
+ var harness = new ManagerHarness();
+
+ harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ [Fact]
+ public void IsUserActive_WithTwoSessionsOfTheSameUser_TracksBothSeparately()
+ {
+ var harness = new ManagerHarness();
+ var second = harness.CreateSession("session-2");
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+ Assert.True(harness.Manager.IsUserActive(harness.User.Id));
+
+ harness.Manager.LeaveGroup(second, new LeaveGroupRequest(), CancellationToken.None);
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ [Fact]
+ public async Task HandleRequest_GroupWaitsForAMemberThatNeverReportsReady_RecoversOnItsOwn()
+ {
+ var harness = new ManagerHarness(groupWaitTimeout: 200);
+ var second = harness.CreateSession("session-2");
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+
+ // Starting playback puts the group behind the ready barrier.
+ harness.Manager.HandleRequest(
+ harness.Session,
+ new PlayGroupRequest(new[] { Guid.NewGuid() }, 0, 0),
+ CancellationToken.None);
+ Assert.Equal(GroupStateType.Waiting, harness.Manager.GetGroup(harness.Session, info.GroupId).State);
+
+ // Neither session ever reports ready, so the group has to come out of the wait by itself.
+ Assert.Equal(
+ GroupStateType.Playing,
+ await harness.WaitForState(harness.Session, info.GroupId, GroupStateType.Playing));
+ }
+
+ private sealed class ManagerHarness
+ {
+ private readonly Mock<ISessionManager> _sessionManager = new();
+
+ public ManagerHarness(long? groupWaitTimeout = null)
+ {
+ var userManager = new Mock<IUserManager>();
+ var libraryManager = new Mock<ILibraryManager>();
+
+ User = new User("tester", "auth-provider", "pwdreset-provider");
+ userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User);
+
+ var item = new Mock<BaseItem>();
+ item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
+ item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks;
+ libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object);
+
+ _sessionManager
+ .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+ _sessionManager
+ .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+
+ Manager = new SyncPlayManager(
+ NullLoggerFactory.Instance,
+ userManager.Object,
+ _sessionManager.Object,
+ libraryManager.Object)
+ {
+ GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout
+ };
+
+ Session = CreateSession("session-1");
+ }
+
+ public SyncPlayManager Manager { get; }
+
+ public User User { get; }
+
+ public SessionInfo Session { get; }
+
+ public SessionInfo CreateSession(string id)
+ {
+ return new SessionInfo(_sessionManager.Object, NullLogger.Instance)
+ {
+ Id = id,
+ UserId = User.Id,
+ UserName = User.Username
+ };
+ }
+
+ public async Task<GroupStateType> WaitForState(SessionInfo session, Guid groupId, GroupStateType expected)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ GroupStateType state;
+ while ((state = Manager.GetGroup(session, groupId).State) != expected && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ return state;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
new file mode 100644
index 0000000000..d3cbc9b8be
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
@@ -0,0 +1,422 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.GroupStates;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Controller.SyncPlay.Requests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class WaitingGroupStateTests
+{
+ [Fact]
+ public void Ready_PlayingSessionReportsPositionFromBeforeSeek_IsCorrected()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(10).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+
+ // One member seeks half an hour in.
+ state.HandleRequest(
+ new SeekGroupRequest(TimeSpan.FromMinutes(40).Ticks),
+ group,
+ GroupStateType.Playing,
+ harness.Second,
+ CancellationToken.None);
+
+ harness.Commands.Clear();
+
+ // The other member has not applied the seek yet and reports the old position, still playing.
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, TimeSpan.FromMinutes(10).Ticks, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ // It must be seeked into position, not accepted as ready and handed a pause command
+ // scheduled the length of the seek into the future.
+ Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek);
+ Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Pause);
+ Assert.True(group.IsBuffering(), "session should still be considered buffering");
+ }
+
+ [Fact]
+ public void Ready_PlayingSessionRecoveringFromALongStall_IsNotSeeked()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(10).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+
+ // The session reports it is buffering. No seek happens, so the group position stays put.
+ state.HandleRequest(
+ new BufferGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Playing,
+ harness.First,
+ CancellationToken.None);
+
+ harness.Commands.Clear();
+
+ // It recovers 45 seconds later, still behind, and must be waited for rather than seeked
+ // forward past content it already buffered.
+ var behind = group.PositionTicks - TimeSpan.FromSeconds(45).Ticks;
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, behind, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek);
+ }
+
+ [Fact]
+ public void Ready_PlayingSessionSlightlyBehindGroup_IsStillTreatedAsCatchingUp()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ // A session that is a couple of seconds behind is genuinely recovering, and the group
+ // is expected to wait for it rather than seek it around.
+ group.PositionTicks = TimeSpan.FromMinutes(30).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetBuffering(harness.First, true);
+ group.SetBuffering(harness.Second, true);
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+ harness.Commands.Clear();
+
+ var clientPosition = group.PositionTicks - TimeSpan.FromSeconds(2).Ticks;
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, clientPosition, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.DoesNotContain(harness.Commands, c => c.Command == SendCommandType.Seek);
+ Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Pause);
+ }
+
+ [Fact]
+ public void Ready_PausedSessionOutOfPosition_IsStillCorrected()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(30).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetBuffering(harness.First, true);
+ group.SetBuffering(harness.Second, true);
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+ harness.Commands.Clear();
+
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.Contains(harness.Commands, c => c.Command == SendCommandType.Seek);
+ }
+
+ [Fact]
+ public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ // Both members report a ping well under the default, so the floor is what decides the delay.
+ group.UpdatePing(harness.First, 10);
+ group.UpdatePing(harness.Second, 10);
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetBuffering(harness.First, true);
+ group.SetBuffering(harness.Second, false);
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+
+ var before = DateTime.UtcNow;
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ // DefaultPing is expressed in milliseconds, so the floor must be converted before being
+ // compared against a tick count. Without the conversion the floor is 500 ticks (0.05 ms)
+ // and never applies.
+ var scheduledDelay = group.LastActivity - before;
+ Assert.True(
+ scheduledDelay >= TimeSpan.FromMilliseconds(group.DefaultPing),
+ $"expected a resume delay of at least {group.DefaultPing} ms, got {scheduledDelay.TotalMilliseconds} ms");
+ }
+
+ [Theory]
+ [InlineData(4_000_000_000L)]
+ [InlineData(1_000_000_000_000_000L)]
+ [InlineData(long.MaxValue)]
+ [InlineData(-1L)]
+ public void UpdatePing_ClientReportsAnUnusablePing_IsClampedAndCannotStallTheGroup(long reportedPing)
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.UpdatePing(harness.First, reportedPing);
+
+ Assert.InRange(group.GetHighestPing(), 0, group.MaxPing);
+
+ // The reported ping is scaled into the group's resume point, so an unclamped value either
+ // pushes playback months out or overflows the arithmetic outright.
+ var state = new PlayingGroupState(NullLoggerFactory.Instance);
+ var before = DateTime.UtcNow;
+ state.HandleRequest(
+ new UnpauseGroupRequest(),
+ group,
+ GroupStateType.Paused,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1));
+ }
+
+ [Fact]
+ public async Task SessionJoined_JoinerNeverReportsReady_GroupResumesWithoutIt()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetState(new PlayingGroupState(NullLoggerFactory.Instance));
+
+ // A session joins while the group is playing: the group pauses and waits for it.
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+
+ // The joiner's player aborts and never reports ready. Without a bounded wait the whole
+ // group stays paused forever.
+ await harness.WaitForState(GroupStateType.Playing);
+
+ // Late buffer reports from the session that missed the deadline must not drag the group
+ // back into waiting.
+ group.HandleRequest(
+ joiner,
+ new BufferGroupRequest(DateTime.UtcNow, 0, false, harness.PlaylistItemId),
+ CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Playing, group.GetInfo().State);
+ }
+
+ [Fact]
+ public async Task SessionJoined_GroupWasPaused_TimeoutLeavesTheGroupPaused()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+
+ // The group has been sitting paused for a while before anyone joins.
+ group.LastActivity = DateTime.UtcNow.AddMinutes(-2);
+ group.SetState(new PausedGroupState(NullLoggerFactory.Instance));
+
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+
+ // A group that was paused must not start playing because a member failed to report ready.
+ await harness.WaitForState(GroupStateType.Paused);
+
+ // Giving up on the joiner must not move the playback position of an already paused group.
+ Assert.Equal(TimeSpan.FromMinutes(5).Ticks, group.PositionTicks);
+
+ // Every member has to be told the group is no longer waiting.
+ var recipients = harness.StateUpdates
+ .Where(update => update.Update.State == GroupStateType.Paused)
+ .Select(update => update.SessionId)
+ .ToList();
+ Assert.Contains(harness.First.Id, recipients);
+ Assert.Contains(harness.Second.Id, recipients);
+ Assert.Contains(joiner.Id, recipients);
+ }
+
+ [Fact]
+ public async Task Ready_ReportedBeforeTheDeadline_GroupDoesNotGiveUpOnAnyone()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetState(new PlayingGroupState(NullLoggerFactory.Instance));
+
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+
+ group.HandleRequest(
+ joiner,
+ new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
+ CancellationToken.None);
+
+ // Everyone reported ready, so no deadline is left to trip and force a spurious unpause.
+ Assert.Equal(GroupStateType.Playing, group.GetInfo().State);
+ Assert.Null(group.GroupWaitDeadline);
+
+ var until = DateTime.UtcNow.AddMilliseconds(3 * 200);
+ while (DateTime.UtcNow < until)
+ {
+ harness.PumpGroupWaitTimeout();
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ Assert.Equal(GroupStateType.Playing, group.GetInfo().State);
+ }
+
+ [Fact]
+ public async Task SetPlaylistItem_AfterATimeout_GroupWaitsForEveryoneAgain()
+ {
+ var harness = new GroupHarness(groupWaitTimeout: 200);
+ var group = harness.Group;
+
+ group.LastActivity = DateTime.UtcNow;
+ group.SetState(new PlayingGroupState(NullLoggerFactory.Instance));
+
+ var joiner = harness.NewSession("joiner");
+ group.SessionJoin(joiner, new JoinGroupRequest(group.GroupId), CancellationToken.None);
+ await harness.WaitForState(GroupStateType.Playing);
+
+ // Giving up on a session lasts only until the group changes what it is playing.
+ group.HandleRequest(
+ harness.First,
+ new SetPlaylistItemGroupRequest(harness.PlaylistItemId),
+ CancellationToken.None);
+
+ Assert.Equal(GroupStateType.Waiting, group.GetInfo().State);
+ Assert.NotNull(group.GroupWaitDeadline);
+ }
+
+ private sealed class GroupHarness
+ {
+ private readonly ISessionManager _sessionManager;
+ private readonly Guid _userId;
+
+ public GroupHarness(long? groupWaitTimeout = null)
+ {
+ var userManager = new Mock<IUserManager>();
+ var sessionManager = new Mock<ISessionManager>();
+ var libraryManager = new Mock<ILibraryManager>();
+
+ var user = new User("tester", "auth-provider", "pwdreset-provider");
+ userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(user);
+
+ var item = new Mock<BaseItem>();
+ item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
+ item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks;
+ libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object);
+
+ sessionManager
+ .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>()))
+ .Callback<string, SendCommand, CancellationToken>((_, command, _) => Commands.Add(command))
+ .Returns(Task.CompletedTask);
+
+ sessionManager
+ .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>()))
+ .Callback((string sessionId, GroupUpdate<GroupStateUpdate> update, CancellationToken _) => StateUpdates.Add((sessionId, update.Data)))
+ .Returns(Task.CompletedTask);
+
+ Group = new SyncPlayGroup(
+ NullLoggerFactory.Instance,
+ userManager.Object,
+ sessionManager.Object,
+ libraryManager.Object)
+ {
+ GroupWaitTimeout = groupWaitTimeout ?? SyncPlayGroup.DefaultGroupWaitTimeout
+ };
+
+ _sessionManager = sessionManager.Object;
+ _userId = user.Id;
+
+ First = NewSession("first");
+ Second = NewSession("second");
+
+ Group.CreateGroup(First, new NewGroupRequest("group"), CancellationToken.None);
+ Group.SessionJoin(Second, new JoinGroupRequest(Group.GroupId), CancellationToken.None);
+ Group.SetPlayQueue(new List<Guid> { Guid.NewGuid() }, 0, 0);
+ PlaylistItemId = Group.PlayQueue.GetPlayingItemPlaylistId();
+ }
+
+ public SyncPlayGroup Group { get; }
+
+ public List<(string SessionId, GroupStateUpdate Update)> StateUpdates { get; } = new();
+
+ public SessionInfo First { get; }
+
+ public SessionInfo Second { get; }
+
+ public Guid PlaylistItemId { get; }
+
+ public List<SendCommand> Commands { get; } = new List<SendCommand>();
+
+ // Mirrors the sweep SyncPlayManager runs on a timer.
+ public void PumpGroupWaitTimeout()
+ {
+ var group = Group;
+
+ // Group lock required as Group is not thread-safe.
+ lock (group)
+ {
+ group.HandleGroupWaitTimeout(CancellationToken.None);
+ }
+ }
+
+ public async Task WaitForState(GroupStateType expected)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ while (Group.GetInfo().State != expected && DateTime.UtcNow < deadline)
+ {
+ PumpGroupWaitTimeout();
+ await Task.Delay(20, TestContext.Current.CancellationToken);
+ }
+
+ Assert.Equal(expected, Group.GetInfo().State);
+ }
+
+ public SessionInfo NewSession(string id)
+ {
+ return new SessionInfo(_sessionManager, NullLogger.Instance)
+ {
+ Id = id,
+ UserId = _userId,
+ UserName = id
+ };
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs
new file mode 100644
index 0000000000..5bb5081b60
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/DeviceAccessHostTests.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Events;
+using Jellyfin.Data.Queries;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Entities.Security;
+using Jellyfin.Server.Implementations.Users;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Querying;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Users;
+
+public class DeviceAccessHostTests
+{
+ [Fact]
+ public async Task OnUserUpdated_LogoutThrows_DoesNotEscapeToThreadPool()
+ {
+ var user = new User("test", "default", "default");
+ var device = new Device(user.Id, "app", "1.0", "device", "device-id");
+
+ var deviceManager = new Mock<IDeviceManager>();
+ deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>()))
+ .Returns(new QueryResult<Device>(new[] { device }));
+ deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false);
+
+ var sessionManager = new Mock<ISessionManager>();
+ sessionManager.Setup(s => s.Logout(It.IsAny<Device>()))
+ .ThrowsAsync(new ObjectDisposedException(nameof(ISessionManager)));
+
+ var userManager = new Mock<IUserManager>();
+ var host = new DeviceAccessHost(
+ userManager.Object,
+ deviceManager.Object,
+ sessionManager.Object,
+ NullLogger<DeviceAccessHost>.Instance);
+ await host.StartAsync(TestContext.Current.CancellationToken);
+
+ var context = new CapturingSynchronizationContext();
+ var previous = SynchronizationContext.Current;
+ SynchronizationContext.SetSynchronizationContext(context);
+ try
+ {
+ userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user));
+ }
+ finally
+ {
+ SynchronizationContext.SetSynchronizationContext(previous);
+ }
+
+ Assert.Empty(context.Exceptions);
+ }
+
+ [Fact]
+ public async Task OnUserUpdated_DeviceNoLongerAllowed_LogsOutDevice()
+ {
+ var user = new User("test", "default", "default");
+ var device = new Device(user.Id, "app", "1.0", "device", "device-id");
+
+ var deviceManager = new Mock<IDeviceManager>();
+ deviceManager.Setup(d => d.GetDevices(It.IsAny<DeviceQuery>()))
+ .Returns(new QueryResult<Device>(new[] { device }));
+ deviceManager.Setup(d => d.CanAccessDevice(user, device.DeviceId)).Returns(false);
+
+ var loggedOut = new TaskCompletionSource();
+ var sessionManager = new Mock<ISessionManager>();
+ sessionManager.Setup(s => s.Logout(It.IsAny<Device>()))
+ .Callback(() => loggedOut.TrySetResult())
+ .Returns(Task.CompletedTask);
+
+ var userManager = new Mock<IUserManager>();
+ var host = new DeviceAccessHost(
+ userManager.Object,
+ deviceManager.Object,
+ sessionManager.Object,
+ NullLogger<DeviceAccessHost>.Instance);
+ await host.StartAsync(TestContext.Current.CancellationToken);
+
+ userManager.Raise(m => m.OnUserUpdated += null, userManager.Object, new GenericEventArgs<User>(user));
+
+ await loggedOut.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ sessionManager.Verify(s => s.Logout(device), Times.Once);
+ }
+
+ private sealed class CapturingSynchronizationContext : SynchronizationContext
+ {
+ public List<Exception> Exceptions { get; } = new List<Exception>();
+
+ public override void Post(SendOrPostCallback d, object? state) => Run(d, state);
+
+ public override void Send(SendOrPostCallback d, object? state) => Run(d, state);
+
+ private void Run(SendOrPostCallback d, object? state)
+ {
+ try
+ {
+ d(state);
+ }
+ catch (Exception ex)
+ {
+ Exceptions.Add(ex);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
index c940f92109..e91ebdf1b6 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs
@@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
@@ -17,6 +18,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Model.Cryptography;
+using MediaBrowser.Model.Users;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
@@ -120,6 +122,27 @@ public sealed class UserManagerUpdateUserTests : IDisposable
}
[Fact]
+ public async Task UpdatePolicyAsync_RaisesOnUserUpdated()
+ {
+ var user = await _userManager.CreateUserAsync("policyeventuser");
+
+ User? updated = null;
+ _userManager.OnUserUpdated += (_, e) => updated = e.Argument;
+
+ await _userManager.UpdatePolicyAsync(
+ user.Id,
+ new UserPolicy
+ {
+ EnableAllDevices = false,
+ AuthenticationProviderId = user.AuthenticationProviderId,
+ PasswordResetProviderId = user.PasswordResetProviderId
+ });
+
+ Assert.NotNull(updated);
+ Assert.Equal(user.Id, updated.Id);
+ }
+
+ [Fact]
public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges()
{
var user = await _userManager.CreateUserAsync("policyuser");
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
index 0a5838c545..79b9d1e2c5 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
@@ -6,8 +6,11 @@ using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Api.Models.LibraryStructureDto;
using Jellyfin.Extensions.Json;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.v3.Priority;
@@ -26,6 +29,45 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl
}
[Fact]
+ [Priority(-3)]
+ public async Task AddVirtualFolder_WithWarmDirectoryServiceCache_InvalidatesTheParentListing()
+ {
+ const string Name = "stale-cache-test";
+
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ var directoryService = _factory.Services.GetRequiredService<IDirectoryService>();
+ var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath;
+
+ // Cache a listing of the libraries root taken before the new folder exists. Everything
+ // resolving through this DirectoryService keeps reading that listing until it is dropped,
+ // so the library stays invisible. Making the caches shared once turned this into a real
+ // test failure, see UpdateLibraryOptions_Valid_Success.
+ Assert.DoesNotContain(
+ directoryService.GetFileSystemEntries(rootFolderPath),
+ x => string.Equals(x.Name, Name, StringComparison.Ordinal));
+
+ var body = new AddVirtualFolderDto()
+ {
+ LibraryOptions = new LibraryOptions()
+ {
+ Enabled = false
+ }
+ };
+
+ using var response = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+
+ Assert.Contains(
+ directoryService.GetFileSystemEntries(rootFolderPath),
+ x => string.Equals(x.Name, Name, StringComparison.Ordinal));
+
+ using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode);
+ }
+
+ [Fact]
[Priority(-1)]
public async Task Post_NewVirtualFolder_NotFound()
{
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs
new file mode 100644
index 0000000000..f84e28de76
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/SyncPlayControllerTests.cs
@@ -0,0 +1,36 @@
+using System.Net;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests.Controllers;
+
+public sealed class SyncPlayControllerTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+
+ public SyncPlayControllerTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public async Task GetGroups_Unauthorized_ReturnsUnauthorized()
+ {
+ var client = _factory.CreateClient();
+
+ var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetGroups_InvalidToken_ReturnsUnauthorized()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader("invalid-token");
+
+ var response = await client.GetAsync("/SyncPlay/List", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+}
diff --git a/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs
new file mode 100644
index 0000000000..81cff49945
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/HostedServiceRegistrationTests.cs
@@ -0,0 +1,26 @@
+using Jellyfin.Server.Implementations.Users;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests;
+
+public sealed class HostedServiceRegistrationTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+
+ public HostedServiceRegistrationTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ public void DeviceAccessHost_IsRegisteredAsHostedService()
+ {
+ _ = _factory.CreateClient();
+
+ var hostedServices = _factory.Services.GetServices<IHostedService>();
+
+ Assert.Contains(hostedServices, service => service is DeviceAccessHost);
+ }
+}
diff --git a/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs
new file mode 100644
index 0000000000..3bd8581a5f
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.Migrations;
+using Jellyfin.Server.Migrations.Stages;
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+public class CodeMigrationTests
+{
+ [Fact]
+ public async Task Perform_LeavesApplicationSingletonsAlive()
+ {
+ var services = new ServiceCollection()
+ .AddLogging()
+ .RegisterStartupLogger()
+ .AddSingleton<ApplicationSingleton>()
+ .AddTransient<MigrationTransient>();
+
+ await using var serviceProvider = services.BuildServiceProvider();
+ var applicationSingleton = serviceProvider.GetRequiredService<ApplicationSingleton>();
+ var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration");
+
+ var migration = new CodeMigration(
+ typeof(TestMigration),
+ new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)),
+ null);
+ await migration.Perform(serviceProvider, logger, CancellationToken.None);
+
+ var performed = TestMigration.Performed;
+ Assert.NotNull(performed);
+ // The migration has to run against the applications own services, and they have to outlive it.
+ Assert.Same(applicationSingleton, performed.Singleton);
+ Assert.False(applicationSingleton.IsDisposed);
+ Assert.Same(applicationSingleton, serviceProvider.GetRequiredService<ApplicationSingleton>());
+ // Services created for the migration itself are still owned by the migration.
+ Assert.True(performed.Transient.IsDisposed);
+ // The startup logger has to stay attached to the topic of the running migration.
+ Assert.Same(logger.Topic, performed.Logger.Topic);
+ }
+
+ [Fact]
+ public async Task Perform_DoesNotLeakTheMigrationTopic()
+ {
+ var services = new ServiceCollection()
+ .AddLogging()
+ .RegisterStartupLogger()
+ .AddSingleton<ApplicationSingleton>()
+ .AddTransient<MigrationTransient>();
+
+ await using var serviceProvider = services.BuildServiceProvider();
+ var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration");
+
+ var migration = new CodeMigration(
+ typeof(TestMigration),
+ new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)),
+ null);
+ await migration.Perform(serviceProvider, logger, CancellationToken.None);
+
+ // The topic belongs to the migration that ran, so loggers resolved afterwards must not still write into it.
+ Assert.Null(serviceProvider.GetRequiredService<IStartupLogger<CodeMigrationTests>>().Topic);
+ Assert.Null(new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ private sealed class ApplicationSingleton : IDisposable
+ {
+ public bool IsDisposed { get; private set; }
+
+ public void Dispose()
+ {
+ IsDisposed = true;
+ }
+ }
+
+ private sealed class MigrationTransient : IDisposable
+ {
+ public bool IsDisposed { get; private set; }
+
+ public void Dispose()
+ {
+ IsDisposed = true;
+ }
+ }
+
+ private sealed class TestMigration : IAsyncMigrationRoutine
+ {
+ public TestMigration(ApplicationSingleton singleton, MigrationTransient transient, IStartupLogger<TestMigration> logger)
+ {
+ Singleton = singleton;
+ Transient = transient;
+ Logger = logger;
+ }
+
+ public static TestMigration? Performed { get; private set; }
+
+ public ApplicationSingleton Singleton { get; }
+
+ public MigrationTransient Transient { get; }
+
+ public IStartupLogger<TestMigration> Logger { get; }
+
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ Performed = this;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs b/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs
new file mode 100644
index 0000000000..25430447d4
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/ConsolidateLocalizedUserViewsTests.cs
@@ -0,0 +1,202 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+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.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Migrations.Routines;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+/// <summary>
+/// Covers the references a database carried up from 10.x can still hold against a view the migration
+/// is about to drop. Only ParentId cascades; everything else here is a NO ACTION foreign key that
+/// used to abort the migration, and with it the whole startup.
+/// </summary>
+public sealed class ConsolidateLocalizedUserViewsTests : IDisposable
+{
+ private const string MetadataPath = "/metadata";
+
+ private static readonly Guid _staleId = new("11111111-1111-1111-1111-111111111111");
+ private static readonly Guid _canonicalId = new("22222222-2222-2222-2222-222222222222");
+
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+
+ public ConsolidateLocalizedUserViewsTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var context = CreateDbContext();
+ context.Database.EnsureCreated();
+ }
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task PerformAsync_ItemOwnedByStaleView_MovesItAndDropsTheView()
+ {
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(StaleView());
+ context.BaseItems.Add(new BaseItemEntity { Id = Guid.NewGuid(), Type = "Trailer", OwnerId = _staleId });
+ await context.SaveChangesAsync(Ct);
+ }
+
+ await CreateMigration().PerformAsync(Ct);
+
+ using (var context = CreateDbContext())
+ {
+ Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct));
+ Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Type == "Trailer", Ct)).OwnerId);
+ }
+ }
+
+ [Fact]
+ public async Task PerformAsync_StaleViewInLinkedChildren_DropsTheLinksAndTheView()
+ {
+ var movieId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(StaleView());
+ context.BaseItems.Add(new BaseItemEntity { Id = movieId, Type = "Movie" });
+ context.LinkedChildren.Add(new LinkedChildEntity { ParentId = _staleId, SortOrder = 0, ChildId = movieId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual });
+ context.LinkedChildren.Add(new LinkedChildEntity { ParentId = movieId, SortOrder = 0, ChildId = _staleId, ChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType.Manual });
+ await context.SaveChangesAsync(Ct);
+ }
+
+ await CreateMigration().PerformAsync(Ct);
+
+ using (var context = CreateDbContext())
+ {
+ Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct));
+ Assert.Empty(context.LinkedChildren);
+ Assert.NotNull(await context.BaseItems.FindAsync([movieId], Ct));
+ }
+ }
+
+ [Fact]
+ public async Task PerformAsync_OrphanedAncestry_IsNotResurrectedUnderTheCanonicalView()
+ {
+ var childId = Guid.NewGuid();
+ var orphanId = Guid.NewGuid();
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.Add(StaleView());
+ context.BaseItems.Add(new BaseItemEntity { Id = childId, Type = "Movie", ParentId = _staleId });
+ await context.SaveChangesAsync(Ct);
+
+ context.AncestorIds.Add(new AncestorId { ItemId = childId, ParentItemId = _staleId, Item = null!, ParentItem = null! });
+ await context.SaveChangesAsync(Ct);
+
+ // Written while foreign keys went unenforced: the item behind it is long gone.
+ await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF", Ct);
+ context.AncestorIds.Add(new AncestorId { ItemId = orphanId, ParentItemId = _staleId, Item = null!, ParentItem = null! });
+ await context.SaveChangesAsync(Ct);
+ await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = ON", Ct);
+ }
+
+ await CreateMigration().PerformAsync(Ct);
+
+ using (var context = CreateDbContext())
+ {
+ Assert.Null(await context.BaseItems.FindAsync([_staleId], Ct));
+ Assert.Equal(_canonicalId, (await context.BaseItems.SingleAsync(e => e.Id.Equals(childId), Ct)).ParentId);
+ var ancestors = await context.AncestorIds.ToListAsync(Ct);
+ Assert.Equal(new[] { childId }, ancestors.Select(e => e.ItemId));
+ Assert.Equal(_canonicalId, ancestors[0].ParentItemId);
+ }
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ private static BaseItemEntity StaleView() => new()
+ {
+ Id = _staleId,
+ Type = "MediaBrowser.Controller.Entities.UserView",
+ Path = Path.Combine(MetadataPath, "views", "livetv")
+ };
+
+ private JellyfinDbContext CreateDbContext() => new(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(new Mock<IApplicationPaths>().Object, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+
+ private ConsolidateLocalizedUserViews CreateMigration()
+ {
+ var view = new UserView
+ {
+ Id = _staleId,
+ Path = Path.Combine(MetadataPath, "views", "livetv"),
+ Name = "Live TV",
+ ViewType = CollectionType.livetv
+ };
+
+ var applicationPaths = new Mock<IServerApplicationPaths>();
+ applicationPaths.Setup(e => e.InternalMetadataPath).Returns(MetadataPath);
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager.Setup(e => e.ApplicationPaths).Returns(applicationPaths.Object);
+
+ var fileSystem = new Mock<IFileSystem>();
+ fileSystem.Setup(e => e.GetValidFilename(It.IsAny<string>())).Returns((string name) => name);
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(e => e.GetItemList(It.IsAny<InternalItemsQuery>()))
+ .Returns(new List<BaseItem> { view });
+ libraryManager.Setup(e => e.GetNewItemId(It.IsAny<string>(), It.IsAny<Type>()))
+ .Returns(_canonicalId);
+ libraryManager.Setup(e => e.CreateItem(It.IsAny<BaseItem>(), It.IsAny<BaseItem?>()))
+ .Callback((BaseItem item, BaseItem? parent) =>
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = item.Id,
+ Type = item.GetType().FullName!,
+ Path = item.Path
+ });
+ context.SaveChanges();
+ });
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>())).ReturnsAsync(CreateDbContext);
+
+ return new ConsolidateLocalizedUserViews(
+ new StartupLogger<ConsolidateLocalizedUserViews>(NullLogger<ConsolidateLocalizedUserViews>.Instance),
+ libraryManager.Object,
+ configurationManager.Object,
+ fileSystem.Object,
+ factory.Object);
+ }
+}
diff --git a/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs b/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs
new file mode 100644
index 0000000000..34eff8a988
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/RepairAlternateVersionLinksTests.cs
@@ -0,0 +1,216 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Migrations.Routines;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Common.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+/// <summary>
+/// Covers the repair of the PrimaryVersionId the item queries hide an alternate version by,
+/// including the link shapes that would otherwise leave a whole version group hidden.
+/// </summary>
+public sealed class RepairAlternateVersionLinksTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly IApplicationPaths _applicationPaths;
+
+ public RepairAlternateVersionLinksTests()
+ {
+ _applicationPaths = new Mock<IApplicationPaths>().Object;
+
+ // The connection owns the in-memory database, so it stays open for the whole test.
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using var context = CreateDbContext();
+ context.Database.EnsureCreated();
+ }
+
+ [Fact]
+ public async Task PerformAsync_VersionLinkedToPrimary_PointsItAtThePrimary()
+ {
+ var primaryId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+ var versionId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+
+ Seed(
+ [(primaryId, null), (versionId, null)],
+ [(primaryId, versionId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ AssertIsVersionOf(context, versionId, primaryId);
+ AssertIsPrimary(context, primaryId);
+ }
+
+ [Fact]
+ public async Task PerformAsync_VideosLinkedAsEachOthersVersion_KeepsOneOfThemVisible()
+ {
+ var firstId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+ var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
+
+ // Each one claims the other as its version, so pointing both at their link would hide the
+ // group in its entirety.
+ Seed(
+ [(firstId, null), (secondId, null)],
+ [(firstId, secondId), (secondId, firstId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ var first = Get(context, firstId);
+ var second = Get(context, secondId);
+
+ var primary = first.PrimaryVersionId is null ? first : second;
+ var version = first.PrimaryVersionId is null ? second : first;
+
+ Assert.Null(primary.PrimaryVersionId);
+ AssertIsPrimary(context, primary.Id);
+ AssertIsVersionOf(context, version.Id, primary.Id);
+ }
+
+ [Fact]
+ public async Task PerformAsync_PrimaryStillPointingAtItsOwnVersion_ClearsTheStalePrimary()
+ {
+ var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
+ var versionId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
+
+ // The primary was promoted over the version it now heads, but kept the pointer to it: the
+ // repair below would point the version back and leave both hidden.
+ Seed(
+ [(primaryId, versionId), (versionId, null)],
+ [(primaryId, versionId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ AssertIsPrimary(context, primaryId);
+ AssertIsVersionOf(context, versionId, primaryId);
+ }
+
+ [Fact]
+ public async Task PerformAsync_ChainedLinks_PointsEveryVersionAtTheHeadOfTheChain()
+ {
+ var headId = Guid.Parse("11111111-1111-1111-1111-111111111111");
+ var middleId = Guid.Parse("22222222-2222-2222-2222-222222222222");
+ var tailId = Guid.Parse("33333333-3333-3333-3333-333333333333");
+
+ // The middle one is a version of the head and a primary of the tail at the same time.
+ Seed(
+ [(headId, null), (middleId, null), (tailId, null)],
+ [(headId, middleId), (middleId, tailId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ AssertIsPrimary(context, headId);
+ AssertIsVersionOf(context, middleId, headId);
+ AssertIsVersionOf(context, tailId, headId);
+ }
+
+ [Fact]
+ public async Task PerformAsync_VersionLinkedToItself_LeavesItVisible()
+ {
+ var itemId = Guid.Parse("44444444-4444-4444-4444-444444444444");
+
+ Seed([(itemId, null)], [(itemId, itemId)]);
+
+ await PerformAsync();
+
+ using var context = CreateDbContext();
+ Assert.Null(Get(context, itemId).PrimaryVersionId);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ private static void AssertIsPrimary(JellyfinDbContext context, Guid id)
+ {
+ var item = Get(context, id);
+ Assert.Null(item.PrimaryVersionId);
+ Assert.Equal(id.ToString("N", CultureInfo.InvariantCulture), item.PresentationUniqueKey);
+ }
+
+ private static void AssertIsVersionOf(JellyfinDbContext context, Guid id, Guid primaryId)
+ {
+ var item = Get(context, id);
+ Assert.Equal(primaryId, item.PrimaryVersionId);
+
+ // Presentation-key grouping has to collapse the version onto its primary as well.
+ Assert.Equal(primaryId.ToString("N", CultureInfo.InvariantCulture), item.PresentationUniqueKey);
+ }
+
+ private static BaseItemEntity Get(JellyfinDbContext context, Guid id)
+ => context.BaseItems.AsNoTracking().First(e => e.Id.Equals(id));
+
+ private JellyfinDbContext CreateDbContext() => new(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+
+ private void Seed(
+ (Guid Id, Guid? PrimaryVersionId)[] items,
+ (Guid ParentId, Guid ChildId)[] links)
+ {
+ using var context = CreateDbContext();
+
+ foreach (var (id, primaryVersionId) in items)
+ {
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = id,
+ Type = "MediaBrowser.Controller.Entities.Movies.Movie",
+ Name = "Movie",
+ PrimaryVersionId = primaryVersionId,
+ PresentationUniqueKey = (primaryVersionId ?? id).ToString("N", CultureInfo.InvariantCulture)
+ });
+ }
+
+ foreach (var (parentId, childId) in links)
+ {
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = parentId,
+ ChildId = childId,
+ ChildType = LinkedChildType.LinkedAlternateVersion
+ });
+ }
+
+ context.SaveChanges();
+ }
+
+ private Task PerformAsync()
+ {
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
+
+ var migration = new RepairAlternateVersionLinks(
+ new StartupLogger<RepairAlternateVersionLinks>(NullLogger<RepairAlternateVersionLinks>.Instance),
+ factory.Object);
+
+ return migration.PerformAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs
new file mode 100644
index 0000000000..c2894e9647
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs
@@ -0,0 +1,54 @@
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.ServerSetupApp;
+
+public class StartupLoggerTests
+{
+ [Fact]
+ public void BeginAmbientTopic_AttachesNewLoggersToTheTopic()
+ {
+ var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration");
+
+ using (StartupLogger.BeginAmbientTopic(migration.Topic))
+ {
+ Assert.Same(migration.Topic, new StartupLogger(NullLogger.Instance).Topic);
+ }
+ }
+
+ [Fact]
+ public void BeginAmbientTopic_RestoresThePreviousTopic()
+ {
+ var root = new StartupLogger(NullLogger.Instance);
+ var outer = root.BeginGroup($"Outer");
+ var inner = outer.BeginGroup($"Inner");
+
+ Assert.Null(new StartupLogger(NullLogger.Instance).Topic);
+
+ using (StartupLogger.BeginAmbientTopic(outer.Topic))
+ {
+ using (StartupLogger.BeginAmbientTopic(inner.Topic))
+ {
+ Assert.Same(inner.Topic, new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ // Leaving a nested topic has to fall back to the enclosing one, not to the setup UI root.
+ Assert.Same(outer.Topic, new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ Assert.Null(new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ [Fact]
+ public void BeginGroup_KeepsAnExplicitTopicOverTheAmbientOne()
+ {
+ var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration");
+ var unrelated = new StartupLogger(NullLogger.Instance).BeginGroup($"Unrelated");
+
+ using (StartupLogger.BeginAmbientTopic(migration.Topic))
+ {
+ Assert.Same(unrelated.Topic, unrelated.With(NullLogger.Instance).Topic);
+ }
+ }
+}
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
index a04b37f215..3767b5c954 100644
--- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
@@ -124,6 +124,27 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers
}
[Fact]
+ public void Fetch_Valid_MultiEpisode_Unordered_Success()
+ {
+ var result = new MetadataResult<Episode>()
+ {
+ Item = new Episode()
+ };
+
+ _parser.Fetch(result, "Test Data/Rising-Reversed.nfo", CancellationToken.None);
+
+ var item = result.Item;
+ // The episodedetails blocks are stored in descending order, the merged episode must still be in ascending order
+ Assert.Equal("Rising (1) / Rising (2)", item.Name);
+ Assert.Equal(1, item.IndexNumber);
+ Assert.Equal(2, item.IndexNumberEnd);
+ Assert.Equal(1, item.ParentIndexNumber);
+ Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy. / Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.", item.Overview);
+ Assert.Equal(new DateTime(2004, 7, 16), item.PremiereDate);
+ Assert.Equal(2004, item.ProductionYear);
+ }
+
+ [Fact]
public void Fetch_Valid_MultiEpisode_With_Missing_Tags_Success()
{
var result = new MetadataResult<Episode>()
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo
new file mode 100644
index 0000000000..6dbab13566
--- /dev/null
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo
@@ -0,0 +1,43 @@
+<episodedetails>
+ <title>Rising (2)</title>
+ <season>1</season>
+ <episode>2</episode>
+ <aired>2004-07-16</aired>
+ <plot>Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.</plot>
+ <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25334.jpg</thumb>
+ <watched>false</watched>
+ <rating>7.9</rating>
+ <actor>
+ <name>Joe Flanigan</name>
+ <role>John Sheppard</role>
+ <order>0</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+ </actor>
+ <actor>
+ <name>David Hewlett</name>
+ <role>Rodney McKay</role>
+ <order>1</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+ </actor>
+</episodedetails><episodedetails>
+ <title>Rising (1)</title>
+ <season>1</season>
+ <episode>1</episode>
+ <aired>2004-07-16</aired>
+ <plot>A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.</plot>
+ <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25333.jpg</thumb>
+ <watched>false</watched>
+ <rating>8.0</rating>
+ <actor>
+ <name>Joe Flanigan</name>
+ <role>John Sheppard</role>
+ <order>0</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+ </actor>
+ <actor>
+ <name>David Hewlett</name>
+ <role>Rodney McKay</role>
+ <order>1</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+ </actor>
+</episodedetails>