aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.devcontainer/devcontainer.json2
-rw-r--r--.editorconfig3
-rw-r--r--.github/CODEOWNERS15
-rw-r--r--.github/ISSUE_TEMPLATE/issue report.yml14
-rw-r--r--.github/workflows/ci-codeql-analysis.yml10
-rw-r--r--.github/workflows/ci-compat.yml24
-rw-r--r--.github/workflows/ci-openapi.yml34
-rw-r--r--.github/workflows/ci-tests.yml6
-rw-r--r--.github/workflows/commands.yml10
-rw-r--r--.github/workflows/issue-stale.yml2
-rw-r--r--.github/workflows/issue-template-check.yml6
-rw-r--r--.github/workflows/pull-request-conflict.yml2
-rw-r--r--.github/workflows/pull-request-stale.yaml2
-rw-r--r--.github/workflows/release-bump-version.yaml4
-rw-r--r--CONTRIBUTORS.md11
-rw-r--r--Directory.Build.props5
-rw-r--r--Directory.Packages.props102
-rw-r--r--Emby.Naming/Common/NamingOptions.cs175
-rw-r--r--Emby.Naming/Emby.Naming.csproj2
-rw-r--r--Emby.Naming/ExternalFiles/ExternalPathParser.cs8
-rw-r--r--Emby.Naming/TV/SeasonPathParser.cs59
-rw-r--r--Emby.Naming/TV/SeriesResolver.cs21
-rw-r--r--Emby.Naming/Video/ExtraRuleResolver.cs78
-rw-r--r--Emby.Naming/Video/StackResolver.cs2
-rw-r--r--Emby.Photos/PhotoProvider.cs4
-rw-r--r--Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs56
-rw-r--r--Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs1
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs57
-rw-r--r--Emby.Server.Implementations/Chapters/ChapterManager.cs300
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs10
-rw-r--r--Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs44
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs212
-rw-r--r--Emby.Server.Implementations/Emby.Server.Implementations.csproj4
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs2
-rw-r--r--Emby.Server.Implementations/IO/FileRefresher.cs2
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitor.cs2
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs59
-rw-r--r--Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs14
-rw-r--r--Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs43
-rw-r--r--Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs96
-rw-r--r--Emby.Server.Implementations/Library/ExternalDataManager.cs77
-rw-r--r--Emby.Server.Implementations/Library/IgnorePatterns.cs3
-rw-r--r--Emby.Server.Implementations/Library/KeyframeManager.cs44
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs519
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs21
-rw-r--r--Emby.Server.Implementations/Library/MusicManager.cs17
-rw-r--r--Emby.Server.Implementations/Library/PathManager.cs34
-rw-r--r--Emby.Server.Implementations/Library/ResolverHelper.cs22
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs25
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs10
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs13
-rw-r--r--Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs69
-rw-r--r--Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs157
-rw-r--r--Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs216
-rw-r--r--Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs69
-rw-r--r--Emby.Server.Implementations/Library/Validators/GenresValidator.cs151
-rw-r--r--Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs69
-rw-r--r--Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs117
-rw-r--r--Emby.Server.Implementations/Library/Validators/PeopleValidator.cs181
-rw-r--r--Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs69
-rw-r--r--Emby.Server.Implementations/Library/Validators/StudiosValidator.cs151
-rw-r--r--Emby.Server.Implementations/Localization/Core/ar.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/be.json78
-rw-r--r--Emby.Server.Implementations/Localization/Core/bg-BG.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/bn.json96
-rw-r--r--Emby.Server.Implementations/Localization/Core/ca.json84
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/de.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-US.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-AR.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-MX.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_419.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_DO.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/et.json36
-rw-r--r--Emby.Server.Implementations/Localization/Core/eu.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr-CA.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/gl.json122
-rw-r--r--Emby.Server.Implementations/Localization/Core/gsw.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json24
-rw-r--r--Emby.Server.Implementations/Localization/Core/he_IL.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/hi.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/ht.json61
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/id.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/is.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/kn.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json92
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/lzh.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/mn.json143
-rw-r--r--Emby.Server.Implementations/Localization/Core/mr.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/ms.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/nl.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/nn.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/pa.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/pl.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/pr.json90
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-BR.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/ru.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sl-SI.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/sq.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/sr.json13
-rw-r--r--Emby.Server.Implementations/Localization/Core/sv.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ta.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/te.json11
-rw-r--r--Emby.Server.Implementations/Localization/Core/th.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/uk.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/ur.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/ur_PK.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/uz.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json16
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs11
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ar.json41
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/bg.json34
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/cz.json34
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/es.json4
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/fi.json15
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/gr.json34
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/hu.json41
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/id.json34
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/in.json55
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/it.json34
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/kr.json41
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/lt.json41
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/nz.json7
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ph.json48
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/pt.json62
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ro.json10
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/sg.json48
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/th.json48
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/tr.json69
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/tw.json41
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ua.json34
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/za.json55
-rw-r--r--Emby.Server.Implementations/Localization/iso6392.txt27
-rw-r--r--Emby.Server.Implementations/MediaEncoder/EncodingManager.cs272
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs9
-rw-r--r--Emby.Server.Implementations/Plugins/PluginManager.cs2
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs987
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/TaskManager.cs379
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs203
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs240
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs101
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs11
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupUserDataTask.cs77
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs203
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs130
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs171
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs9
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs114
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs148
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs164
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs83
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs121
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs155
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs69
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs161
-rw-r--r--Emby.Server.Implementations/ServerApplicationPaths.cs7
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs16
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs36
-rw-r--r--Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs6
-rw-r--r--Emby.Server.Implementations/Sorting/DatePlayedComparer.cs8
-rw-r--r--Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs9
-rw-r--r--Emby.Server.Implementations/Sorting/IsPlayedComparer.cs9
-rw-r--r--Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs9
-rw-r--r--Emby.Server.Implementations/Sorting/PlayCountComparer.cs8
-rw-r--r--Emby.Server.Implementations/Sorting/StartDateComparer.cs1
-rw-r--r--Emby.Server.Implementations/SystemManager.cs5
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs9
-rw-r--r--Jellyfin.Api/Controllers/BackupController.cs127
-rw-r--r--Jellyfin.Api/Controllers/DisplayPreferencesController.cs7
-rw-r--r--Jellyfin.Api/Controllers/DynamicHlsController.cs12
-rw-r--r--Jellyfin.Api/Controllers/ImageController.cs137
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs13
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs3
-rw-r--r--Jellyfin.Api/Controllers/LocalizationController.cs11
-rw-r--r--Jellyfin.Api/Controllers/MediaSegmentsController.cs5
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs39
-rw-r--r--Jellyfin.Api/Controllers/PlaystateController.cs3
-rw-r--r--Jellyfin.Api/Controllers/SyncPlayController.cs2
-rw-r--r--Jellyfin.Api/Controllers/SystemController.cs1
-rw-r--r--Jellyfin.Api/Controllers/YearsController.cs7
-rw-r--r--Jellyfin.Api/Formatters/XmlOutputFormatter.cs25
-rw-r--r--Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs60
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs11
-rw-r--r--Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs20
-rw-r--r--Jellyfin.Api/Middleware/LanFilteringMiddleware.cs51
-rw-r--r--Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs23
-rw-r--r--Jellyfin.Data/Jellyfin.Data.csproj2
-rw-r--r--Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs61
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs19
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs15
-rw-r--r--Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs559
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs971
-rw-r--r--Jellyfin.Server.Implementations/Item/ChapterRepository.cs42
-rw-r--r--Jellyfin.Server.Implementations/Item/KeyframeRepository.cs17
-rw-r--r--Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs10
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs72
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs109
-rw-r--r--Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs199
-rw-r--r--Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs9
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs173
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs6
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs40
-rw-r--r--Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs165
-rw-r--r--Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs6
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs31
-rw-r--r--Jellyfin.Server/CoreAppHost.cs2
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs10
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs49
-rw-r--r--Jellyfin.Server/Filters/AdditionalModelFilter.cs2
-rw-r--r--Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs51
-rw-r--r--Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs36
-rw-r--r--Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs11
-rw-r--r--Jellyfin.Server/Helpers/StartupHelpers.cs6
-rw-r--r--Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs151
-rw-r--r--Jellyfin.Server/Jellyfin.Server.csproj7
-rw-r--r--Jellyfin.Server/Migrations/IAsyncMigrationRoutine.cs31
-rw-r--r--Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs2
-rw-r--r--Jellyfin.Server/Migrations/IMigrationRoutine.cs32
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs68
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs35
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs459
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs204
-rw-r--r--Jellyfin.Server/Migrations/MigrationsFactory.cs20
-rw-r--r--Jellyfin.Server/Migrations/MigrationsListStore.cs24
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs12
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs12
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs12
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs10
-rw-r--r--Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs47
-rw-r--r--Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs32
-rw-r--r--Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixAudioData.cs37
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixDates.cs171
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs30
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs33
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs30
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs103
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs211
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs73
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs123
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs111
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs346
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs218
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs46
-rw-r--r--Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs65
-rw-r--r--Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs131
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs69
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs111
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs12
-rw-r--r--Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs74
-rw-r--r--Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs12
-rw-r--r--Jellyfin.Server/Migrations/Stages/CodeMigration.cs87
-rw-r--r--Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs26
-rw-r--r--Jellyfin.Server/Migrations/Stages/MigrationStage.cs16
-rw-r--r--Jellyfin.Server/Program.cs107
-rw-r--r--Jellyfin.Server/ServerSetupApp/IStartupLogger.cs66
-rw-r--r--Jellyfin.Server/ServerSetupApp/SetupServer.cs182
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs31
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLogger.cs124
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs18
-rw-r--r--Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs56
-rw-r--r--Jellyfin.Server/ServerSetupApp/index.mstemplate.html235
-rw-r--r--Jellyfin.Server/Startup.cs17
-rw-r--r--Jellyfin.Server/StartupOptions.cs6
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/banner-dark.svg34
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/jellyfin.svg26
-rw-r--r--Jellyfin.Server/wwwroot/api-docs/swagger/custom.css12
-rw-r--r--Jellyfin.sln10
-rw-r--r--MediaBrowser.Common/Configuration/EncodingConfigurationExtensions.cs3
-rw-r--r--MediaBrowser.Common/Configuration/IApplicationPaths.cs19
-rw-r--r--MediaBrowser.Common/Extensions/HttpContextExtensions.cs2
-rw-r--r--MediaBrowser.Common/MediaBrowser.Common.csproj2
-rw-r--r--MediaBrowser.Common/Net/INetworkManager.cs4
-rw-r--r--MediaBrowser.Common/Net/RemoteAccessPolicyResult.cs29
-rw-r--r--MediaBrowser.Common/Plugins/BasePluginOfT.cs5
-rw-r--r--MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs2
-rw-r--r--MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs5
-rw-r--r--MediaBrowser.Controller/Chapters/IChapterManager.cs57
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs118
-rw-r--r--MediaBrowser.Controller/Entities/BaseItemExtensions.cs14
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs314
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs12
-rw-r--r--MediaBrowser.Controller/Entities/Movies/BoxSet.cs6
-rw-r--r--MediaBrowser.Controller/Entities/TV/Episode.cs39
-rw-r--r--MediaBrowser.Controller/Entities/TV/Season.cs4
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs7
-rw-r--r--MediaBrowser.Controller/Entities/UserRootFolder.cs2
-rw-r--r--MediaBrowser.Controller/Entities/UserView.cs10
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs45
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs32
-rw-r--r--MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs8
-rw-r--r--MediaBrowser.Controller/IDisplayPreferencesManager.cs11
-rw-r--r--MediaBrowser.Controller/IO/FileSystemHelper.cs79
-rw-r--r--MediaBrowser.Controller/IO/IExternalDataManager.cs19
-rw-r--r--MediaBrowser.Controller/IO/IPathManager.cs26
-rw-r--r--MediaBrowser.Controller/IServerApplicationHost.cs5
-rw-r--r--MediaBrowser.Controller/IServerApplicationPaths.cs4
-rw-r--r--MediaBrowser.Controller/Library/IKeyframeManager.cs37
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs19
-rw-r--r--MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs23
-rw-r--r--MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs335
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj2
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs102
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs20
-rw-r--r--MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs12
-rw-r--r--MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs28
-rw-r--r--MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs8
-rw-r--r--MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs24
-rw-r--r--MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs6
-rw-r--r--MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs27
-rw-r--r--MediaBrowser.Controller/Persistence/IChapterRepository.cs (renamed from MediaBrowser.Controller/Chapters/IChapterRepository.cs)30
-rw-r--r--MediaBrowser.Controller/Persistence/IItemRepository.cs32
-rw-r--r--MediaBrowser.Controller/Persistence/IKeyframeRepository.cs8
-rw-r--r--MediaBrowser.Controller/Playlists/Playlist.cs6
-rw-r--r--MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs2
-rw-r--r--MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs34
-rw-r--r--MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs29
-rw-r--r--MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs15
-rw-r--r--MediaBrowser.Controller/SystemBackupService/IBackupService.cs48
-rw-r--r--MediaBrowser.Controller/Trickplay/ITrickplayManager.cs12
-rw-r--r--MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs3
-rw-r--r--MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs4
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs18
-rw-r--r--MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs16
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs67
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs69
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs104
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs121
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs2
-rw-r--r--MediaBrowser.Model/Configuration/ServerConfiguration.cs2
-rw-r--r--MediaBrowser.Model/Dlna/ResolutionNormalizer.cs7
-rw-r--r--MediaBrowser.Model/Dto/BaseItemDto.cs2
-rw-r--r--MediaBrowser.Model/Dto/ItemCounts.cs9
-rw-r--r--MediaBrowser.Model/Dto/TrickplayInfoDto.cs62
-rw-r--r--MediaBrowser.Model/Dto/UserDto.cs9
-rw-r--r--MediaBrowser.Model/Entities/MediaStream.cs54
-rw-r--r--MediaBrowser.Model/Extensions/EnumerableExtensions.cs2
-rw-r--r--MediaBrowser.Model/IO/AsyncFile.cs8
-rw-r--r--MediaBrowser.Model/Lyrics/LyricLineCue.cs13
-rw-r--r--MediaBrowser.Model/MediaBrowser.Model.csproj2
-rw-r--r--MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs8
-rw-r--r--MediaBrowser.Model/Net/MimeTypes.cs1
-rw-r--r--MediaBrowser.Model/Session/QueueItem.cs11
-rw-r--r--MediaBrowser.Model/Users/ForgotPasswordAction.cs4
-rw-r--r--MediaBrowser.Providers/Books/AudioBookMetadataService.cs78
-rw-r--r--MediaBrowser.Providers/Books/BookMetadataService.cs56
-rw-r--r--MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs120
-rw-r--r--MediaBrowser.Providers/Channels/ChannelMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/Folders/FolderMetadataService.cs48
-rw-r--r--MediaBrowser.Providers/Folders/UserViewMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/Genres/GenreMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/Lyric/LrcLyricParser.cs62
-rw-r--r--MediaBrowser.Providers/Manager/ImageSaver.cs16
-rw-r--r--MediaBrowser.Providers/Manager/ItemImageProvider.cs4
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs182
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs14
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs174
-rw-r--r--MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs66
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs17
-rw-r--r--MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs1
-rw-r--r--MediaBrowser.Providers/Movies/MovieMetadataService.cs60
-rw-r--r--MediaBrowser.Providers/Movies/TrailerMetadataService.cs62
-rw-r--r--MediaBrowser.Providers/Music/AlbumMetadataService.cs349
-rw-r--r--MediaBrowser.Providers/Music/ArtistMetadataService.cs67
-rw-r--r--MediaBrowser.Providers/Music/AudioMetadataService.cs109
-rw-r--r--MediaBrowser.Providers/Music/MusicVideoMetadataService.cs87
-rw-r--r--MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/People/PersonMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/Photos/PhotoMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs4
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs108
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs9
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalUrlProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalUrlProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs9
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs15
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html34
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs92
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs3
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs105
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs85
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs78
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs64
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs4
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs23
-rw-r--r--MediaBrowser.Providers/Studios/StudioMetadataService.cs40
-rw-r--r--MediaBrowser.Providers/TV/EpisodeMetadataService.cs162
-rw-r--r--MediaBrowser.Providers/TV/SeasonMetadataService.cs153
-rw-r--r--MediaBrowser.Providers/TV/SeriesMetadataService.cs417
-rw-r--r--MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs10
-rw-r--r--MediaBrowser.Providers/Trickplay/TrickplayProvider.cs4
-rw-r--r--MediaBrowser.Providers/Videos/VideoMetadataService.cs48
-rw-r--r--MediaBrowser.Providers/Years/YearMetadataService.cs40
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs32
-rw-r--r--MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs8
-rw-r--r--README.md4
-rw-r--r--SharedVersion.cs4
-rwxr-xr-xbump_version2
-rw-r--r--src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md9
-rw-r--r--src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs82
-rw-r--r--src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj17
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOption.cs19
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOptions.cs32
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs13
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs22
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs4
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs1
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs1
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs5
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs24
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj14
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs55
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs194
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs32
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs41
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs161
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs296
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs9
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/ProgressablePartitionReporting.cs55
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs215
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.Designer.cs1693
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs39
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.Designer.cs1709
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs37
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.Designer.cs1709
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs22
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.Designer.cs1721
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs52
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.Designer.cs1721
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs54
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs42
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs4
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs108
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs118
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs72
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaExtensions.cs58
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaHelper.cs12
-rw-r--r--src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs8
-rw-r--r--src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs81
-rw-r--r--src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs8
-rw-r--r--src/Jellyfin.Drawing/ImageProcessor.cs6
-rw-r--r--src/Jellyfin.Extensions/DictionaryExtensions.cs33
-rw-r--r--src/Jellyfin.Extensions/FileHelper.cs20
-rw-r--r--src/Jellyfin.Extensions/Jellyfin.Extensions.csproj2
-rw-r--r--src/Jellyfin.Extensions/StringExtensions.cs13
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelManager.cs11
-rw-r--r--src/Jellyfin.LiveTv/IO/EncodedRecorder.cs4
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs34
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs2
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs7
-rw-r--r--src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs2
-rw-r--r--src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs9
-rw-r--r--src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj13
-rw-r--r--src/Jellyfin.Networking/Manager/NetworkManager.cs47
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs1
-rw-r--r--tests/Jellyfin.Extensions.Tests/FileHelperTests.cs23
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs4
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml2
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml2
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs27
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs1
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs11
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs2
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkParseTests.cs43
-rw-r--r--tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs16
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs19
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs1
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs1
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs184
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/CoreResolutionIgnoreRuleTest.cs129
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs30
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs11
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs2
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs15
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs69
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs3
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs19
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/Lilo & Stitch.nfo1
521 files changed, 26243 insertions, 8105 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 76e6bce7b..029a48f6a 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "9.0.4",
+ "version": "9.0.11",
"commands": [
"dotnet-ef"
]
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index c2127ba5c..8b6b12c31 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -13,7 +13,7 @@
"dotnetRuntimeVersions": "9.0",
"aspNetCoreRuntimeVersions": "9.0"
},
- "ghcr.io/devcontainers-contrib/features/apt-packages:1": {
+ "ghcr.io/devcontainers-extra/features/apt-packages:1": {
"preserve_apt_list": false,
"packages": [
"libfontconfig1"
diff --git a/.editorconfig b/.editorconfig
index ab5d3d9dd..313b02563 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -294,6 +294,9 @@ dotnet_diagnostic.CA1854.severity = error
# error on CA1860: Avoid using 'Enumerable.Any()' extension method
dotnet_diagnostic.CA1860.severity = error
+# error on CA1861: Avoid constant arrays as arguments
+dotnet_diagnostic.CA1861.severity = error
+
# error on CA1862: Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
dotnet_diagnostic.CA1862.severity = error
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index e1900d583..ef81678dd 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,4 +1,11 @@
-# Joshua must review all changes to deployment and build.sh
-.ci/* @joshuaboniface
-deployment/* @joshuaboniface
-build.sh @joshuaboniface
+# Joshua must review all changes to bump_version and any files it touches
+bump_version @joshuaboniface
+.github/ISSUE_TEMPLATE @joshuaboniface
+MediaBrowser.Common/MediaBrowser.Common.csproj @joshuaboniface
+Jellyfin.Data/Jellyfin.Data.csproj @joshuaboniface
+MediaBrowser.Controller/MediaBrowser.Controller.csproj @joshuaboniface
+MediaBrowser.Model/MediaBrowser.Model.csproj @joshuaboniface
+Emby.Naming/Emby.Naming.csproj @joshuaboniface
+src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @joshuaboniface
+# Core must approve all changes within the repo config
+.github/ @jellyfin/core
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml
index 4f58c5bc5..2ed8b0512 100644
--- a/.github/ISSUE_TEMPLATE/issue report.yml
+++ b/.github/ISSUE_TEMPLATE/issue report.yml
@@ -1,6 +1,7 @@
name: Issue Report
description: File an issue report
labels: [bug, triage]
+type: Bug
body:
- type: markdown
id: introduction
@@ -86,7 +87,10 @@ body:
label: Jellyfin Server version
description: What version of Jellyfin are you using?
options:
- - 10.10.0+
+ - 10.11.3
+ - 10.11.2
+ - 10.11.1
+ - 10.11.0
- Master
- Unstable
- Older*
@@ -140,7 +144,9 @@ body:
- **Reverse Proxy**: [e.g. none, nginx, apache, etc.]
- **Base URL**: [e.g. none, yes: /example]
- **Networking**: [e.g. Host, Bridge/NAT]
- - **Storage**: [e.g. local, NFS, cloud]
+ - **Jellyfin Data Storage**: [e.g. local SATA SSD, local HDD]
+ - **Media Storage**: [e.g. Local HDD, SMB Share]
+ - **External Integrations**: [e.g. Jellystat, Jellyseerr]
value: |
- OS:
- Linux Kernel:
@@ -155,7 +161,9 @@ body:
- Reverse Proxy:
- Base URL:
- Networking:
- - Storage:
+ - Jellyfin Data Storage:
+ - Media Storage:
+ - External Integrations:
render: markdown
validations:
required: true
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 1eced1913..2918bd6a7 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -20,18 +20,18 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup .NET
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
+ uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
+ uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3.28.15
+ uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml
index 13b029e52..298c21d7f 100644
--- a/.github/workflows/ci-compat.yml
+++ b/.github/workflows/ci-compat.yml
@@ -11,13 +11,13 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
@@ -26,7 +26,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: abi-head
retention-days: 14
@@ -40,14 +40,14 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Setup .NET
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
@@ -65,7 +65,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: abi-base
retention-days: 14
@@ -85,13 +85,13 @@ jobs:
steps:
- name: Download abi-head
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: abi-head
path: abi-head
- name: Download abi-base
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: abi-base
path: abi-base
@@ -105,7 +105,7 @@ jobs:
run: |
{
echo 'body<<EOF'
- for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll; do
+ for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll Jellyfin.MediaEncoding.Keyframes.dll Jellyfin.Database.Implementations.dll; do
COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 )"
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
printf "\n${file}\n${COMPAT_OUTPUT}\n"
@@ -115,7 +115,7 @@ jobs:
} >> $GITHUB_OUTPUT
- name: Find difference comment
- uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
+ uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -123,7 +123,7 @@ jobs:
body-includes: abi-diff-workflow-comment
- name: Reply or edit difference comment (changed)
- uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
+ uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.diff.outputs.body != '' }}
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -142,7 +142,7 @@ jobs:
</details>
- name: Reply or edit difference comment (unchanged)
- uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
+ uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
with:
issue-number: ${{ github.event.pull_request.number }}
diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml
index 95e090f9b..64742220a 100644
--- a/.github/workflows/ci-openapi.yml
+++ b/.github/workflows/ci-openapi.yml
@@ -16,18 +16,18 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: openapi-head
retention-days: 14
@@ -41,7 +41,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -55,13 +55,13 @@ jobs:
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
git checkout --progress --force $ANCESTOR_REF
- name: Setup .NET
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
+ uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: openapi-base
retention-days: 14
@@ -80,12 +80,12 @@ jobs:
- openapi-base
steps:
- name: Download openapi-head
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-base
path: openapi-base
@@ -120,14 +120,14 @@ jobs:
echo "" >> openapi-changes-reply.md
echo "</details>" >> openapi-changes-reply.md
- name: Find difference comment
- uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
+ uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
direction: last
body-includes: openapi-diff-workflow-comment
- name: Reply or edit difference comment (changed)
- uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
+ uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -135,7 +135,7 @@ jobs:
edit-mode: replace
body-path: openapi-changes-reply.md
- name: Edit difference comment (unchanged)
- uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
+ uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }}
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -158,12 +158,12 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
- name: Download openapi-head
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-head
path: openapi-head
- name: Upload openapi.json (unstable) to repository server
- uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
+ uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
@@ -172,7 +172,7 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (unstable) into place
- uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
+ uses: appleboy/ssh-action@91f3272fc5907f4699dcf59761eb622a07342f5a # v1.2.3
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
@@ -220,12 +220,12 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Download openapi-head
- uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
+ uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-head
path: openapi-head
- name: Upload openapi.json (stable) to repository server
- uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
+ uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
@@ -234,7 +234,7 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (stable) into place
- uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
+ uses: appleboy/ssh-action@91f3272fc5907f4699dcf59761eb622a07342f5a # v1.2.3
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index be4192a44..b2ac7a56e 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -20,9 +20,9 @@ jobs:
runs-on: "${{ matrix.os }}"
steps:
- - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
+ - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: ${{ env.SDK_VERSION }}
@@ -35,7 +35,7 @@ jobs:
--verbosity minimal
- name: Merge code coverage results
- uses: danielpalme/ReportGenerator-GitHub-Action@25b1e0261a9f68d7874dbbace168300558ef68f7 # v5.4.5
+ uses: danielpalme/ReportGenerator-GitHub-Action@dcdfb6e704e87df6b2ed0cf123a6c9f69e364869 # v5.5.0
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index 5ec4d164a..4ec92d42e 100644
--- a/.github/workflows/commands.yml
+++ b/.github/workflows/commands.yml
@@ -17,14 +17,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify as seen
- uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
+ uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
token: ${{ secrets.JF_BOT_TOKEN }}
comment-id: ${{ github.event.comment.id }}
reactions: '+1'
- name: Checkout the latest code
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -40,13 +40,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
- python-version: '3.13'
+ python-version: '3.14'
cache: 'pip'
- name: install python packages
run: pip install -r rename/requirements.txt
diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml
index 624ea564f..db22848c3 100644
--- a/.github/workflows/issue-stale.yml
+++ b/.github/workflows/issue-stale.yml
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
+ - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml
index 8a21ab015..50453d112 100644
--- a/.github/workflows/issue-template-check.yml
+++ b/.github/workflows/issue-template-check.yml
@@ -10,13 +10,13 @@ jobs:
issues: write
steps:
- name: pull in script
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
+ uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
- python-version: '3.13'
+ python-version: '3.14'
cache: 'pip'
- name: install python packages
run: pip install -r main-repo-triage/requirements.txt
diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml
index 411ebf829..e6a9bf0ca 100644
--- a/.github/workflows/pull-request-conflict.yml
+++ b/.github/workflows/pull-request-conflict.yml
@@ -12,7 +12,7 @@ jobs:
label:
name: Labeling
runs-on: ubuntu-latest
- if: ${{ github.repository == 'jellyfin/jellyfin' }}
+ if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }}
steps:
- name: Apply label
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml
index 7ce5b0fa6..223ffc590 100644
--- a/.github/workflows/pull-request-stale.yaml
+++ b/.github/workflows/pull-request-stale.yaml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
+ - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml
index 0da9c44f8..fa4025de5 100644
--- a/.github/workflows/release-bump-version.yaml
+++ b/.github/workflows/release-bump-version.yaml
@@ -33,7 +33,7 @@ jobs:
yq-version: v4.9.8
- name: Checkout Repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
ref: ${{ env.TAG_BRANCH }}
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 0dcce1ea1..0a4114478 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -27,9 +27,11 @@
- [cryptobank](https://github.com/cryptobank)
- [cvium](https://github.com/cvium)
- [dannymichel](https://github.com/dannymichel)
+ - [darioackermann](https://github.com/darioackermann)
- [DaveChild](https://github.com/DaveChild)
- [DavidFair](https://github.com/DavidFair)
- [Delgan](https://github.com/Delgan)
+ - [Derpipose](https://github.com/Derpipose)
- [dcrdev](https://github.com/dcrdev)
- [dhartung](https://github.com/dhartung)
- [dinki](https://github.com/dinki)
@@ -60,6 +62,7 @@
- [ikomhoog](https://github.com/ikomhoog)
- [iwalton3](https://github.com/iwalton3)
- [jftuga](https://github.com/jftuga)
+ - [jkhsjdhjs](https://github.com/jkhsjdhjs)
- [jmshrv](https://github.com/jmshrv)
- [joern-h](https://github.com/joern-h)
- [joshuaboniface](https://github.com/joshuaboniface)
@@ -138,6 +141,7 @@
- [ThibaultNocchi](https://github.com/ThibaultNocchi)
- [thornbill](https://github.com/thornbill)
- [ThreeFive-O](https://github.com/ThreeFive-O)
+ - [tjwalkr3](https://github.com/tjwalkr3)
- [TrisMcC](https://github.com/TrisMcC)
- [trumblejoe](https://github.com/trumblejoe)
- [TtheCreator](https://github.com/TtheCreator)
@@ -195,6 +199,12 @@
- [Kenneth Cochran](https://github.com/kennethcochran)
- [benedikt257](https://github.com/benedikt257)
- [revam](https://github.com/revam)
+ - [allesmi](https://github.com/allesmi)
+ - [ThunderClapLP](https://github.com/ThunderClapLP)
+ - [Shoham Peller](https://github.com/spellr)
+ - [theshoeshiner](https://github.com/theshoeshiner)
+ - [TokerX](https://github.com/TokerX)
+ - [GeneMarks](https://github.com/GeneMarks)
# Emby Contributors
@@ -270,3 +280,4 @@
- [Nathan McCrina](https://github.com/nfmccrina)
- [Martin Reuter](https://github.com/reuterma24)
- [Michael McElroy](https://github.com/mcmcelro)
+ - [Soumyadip Auddy](https://github.com/SoumyadipAuddy)
diff --git a/Directory.Build.props b/Directory.Build.props
index 31ae8bfbe..8400f4c5e 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -19,4 +19,9 @@
<AdditionalFiles Include="$(MSBuildThisFileDirectory)/stylecop.json" />
</ItemGroup>
+ <!-- Custom Analyzers -->
+ <ItemGroup Condition=" '$(MSBuildProjectName)' != 'Jellyfin.CodeAnalysis' AND '$(Configuration)' == 'Debug' ">
+ <ProjectReference Include="$(MSBuildThisFileDirectory)src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj" OutputItemType="Analyzer" />
+ </ItemGroup>
+
</Project>
diff --git a/Directory.Packages.props b/Directory.Packages.props
index cdce608d9..210cbd35d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,85 +4,93 @@
</PropertyGroup>
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
<ItemGroup Label="Package Dependencies">
- <PackageVersion Include="AsyncKeyedLock" Version="7.1.6" />
+ <PackageVersion Include="AsyncKeyedLock" Version="7.1.7" />
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="BDInfo" Version="0.8.0" />
- <PackageVersion Include="BitFaster.Caching" Version="2.5.3" />
- <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
- <PackageVersion Include="BlurHashSharp" Version="1.3.4" />
+ <PackageVersion Include="BitFaster.Caching" Version="2.5.4" />
+ <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
+ <PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
- <PackageVersion Include="Diacritics" Version="3.3.29" />
+ <PackageVersion Include="Diacritics" Version="4.0.17" />
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
- <PackageVersion Include="FsCheck.Xunit" Version="3.2.0" />
- <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.3" />
+ <PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
+ <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
+ <PackageVersion Include="Ignore" Version="0.2.1" />
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
<PackageVersion Include="libse" Version="4.0.12" />
- <PackageVersion Include="LrcParser" Version="2025.228.1" />
+ <PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
- <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.4" />
- <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
- <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
- <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.4" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.4" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.4" />
- <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.4" />
- <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
+ <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
+ <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
+ <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
+ <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
+ <PackageVersion Include="Morestachio" Version="5.0.1.631" />
<PackageVersion Include="Moq" Version="4.18.4" />
- <PackageVersion Include="NEbml" Version="0.12.0" />
- <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
+ <PackageVersion Include="NEbml" Version="1.1.0.5" />
+ <PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
+ <PackageVersion Include="Polly" Version="8.6.5" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
+ <PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
- <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
- <PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
+ <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
+ <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
- <PackageVersion Include="SkiaSharp" Version="2.88.9" />
- <PackageVersion Include="SkiaSharp.HarfBuzz" Version="2.88.9" />
- <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.9" />
+ <!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
+ <PackageVersion Include="SkiaSharp" Version="3.116.1" />
+ <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" />
+ <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
- <PackageVersion Include="Svg.Skia" Version="2.0.0.8" />
+ <PackageVersion Include="Svg.Skia" Version="3.2.1" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageVersion Include="System.Globalization" Version="4.3.0" />
- <PackageVersion Include="System.Linq.Async" Version="6.0.1" />
- <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.4" />
- <PackageVersion Include="System.Text.Json" Version="9.0.4" />
- <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.4" />
+ <PackageVersion Include="System.Linq.Async" Version="6.0.3" />
+ <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
+ <PackageVersion Include="System.Text.Json" Version="9.0.11" />
+ <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
- <PackageVersion Include="z440.atl.core" Version="6.21.0" />
- <PackageVersion Include="TMDbLib" Version="2.2.0" />
- <PackageVersion Include="UTF.Unknown" Version="2.5.1" />
+ <PackageVersion Include="z440.atl.core" Version="7.9.0" />
+ <PackageVersion Include="TMDbLib" Version="2.3.0" />
+ <PackageVersion Include="UTF.Unknown" Version="2.6.0" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs
index 6a662aaf5..f61ca7e12 100644
--- a/Emby.Naming/Common/NamingOptions.cs
+++ b/Emby.Naming/Common/NamingOptions.cs
@@ -21,8 +21,8 @@ namespace Emby.Naming.Common
/// </summary>
public NamingOptions()
{
- VideoFileExtensions = new[]
- {
+ VideoFileExtensions =
+ [
".001",
".3g2",
".3gp",
@@ -77,10 +77,10 @@ namespace Emby.Naming.Common
".wmv",
".wtv",
".xvid"
- };
+ ];
- VideoFlagDelimiters = new[]
- {
+ VideoFlagDelimiters =
+ [
'(',
')',
'-',
@@ -88,15 +88,15 @@ namespace Emby.Naming.Common
'_',
'[',
']'
- };
+ ];
- StubFileExtensions = new[]
- {
+ StubFileExtensions =
+ [
".disc"
- };
+ ];
- StubTypes = new[]
- {
+ StubTypes =
+ [
new StubTypeRule(
stubType: "dvd",
token: "dvd"),
@@ -136,32 +136,32 @@ namespace Emby.Naming.Common
new StubTypeRule(
stubType: "tv",
token: "DSR")
- };
+ ];
- VideoFileStackingRules = new[]
- {
+ VideoFileStackingRules =
+ [
new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[0-9]+)[\)\]]?(?:\.[^.]+)?$", true),
new FileStackRule(@"^(?<filename>.*?)(?:(?<=[\]\)\}])|[ _.-]+)[\(\[]?(?<parttype>cd|dvd|part|pt|dis[ck])[ _.-]*(?<number>[a-d])[\)\]]?(?:\.[^.]+)?$", false)
- };
+ ];
- CleanDateTimes = new[]
- {
+ CleanDateTimes =
+ [
@"(.+[^_\,\.\(\)\[\]\-])[_\.\(\)\[\]\-](19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*",
@"(.+[^_\,\.\(\)\[\]\-])[ _\.\(\)\[\]\-]+(19[0-9]{2}|20[0-9]{2})(?![0-9]+|\W[0-9]{2}\W[0-9]{2})([ _\,\.\(\)\[\]\-][^0-9]|).*(19[0-9]{2}|20[0-9]{2})*"
- };
+ ];
- CleanStrings = new[]
- {
+ CleanStrings =
+ [
@"^\s*(?<cleaned>.+?)[ _\,\.\(\)\[\]\-](3d|sbs|tab|hsbs|htab|mvc|HDR|HDC|UHD|UltraHD|4k|ac3|dts|custom|dc|divx|divx5|dsr|dsrip|dutch|dvd|dvdrip|dvdscr|dvdscreener|screener|dvdivx|cam|fragment|fs|hdtv|hdrip|hdtvrip|internal|limited|multi|subs|ntsc|ogg|ogm|pal|pdtv|proper|repack|rerip|retail|cd[1-9]|r5|bd5|bd|se|svcd|swedish|german|read.nfo|nfofix|unrated|ws|telesync|ts|telecine|tc|brrip|bdrip|480p|480i|576p|576i|720p|720i|1080p|1080i|2160p|hrhd|hrhdtv|hddvd|bluray|blu-ray|x264|x265|h264|h265|xvid|xvidvd|xxx|www.www|AAC|DTS|\[.*\])([ _\,\.\(\)\[\]\-]|$)",
@"^(?<cleaned>.+?)(\[.*\])",
@"^\s*(?<cleaned>.+?)\WE[0-9]+(-|~)E?[0-9]+(\W|$)",
@"^\s*\[[^\]]+\](?!\.\w+$)\s*(?<cleaned>.+)",
@"^\s*(?<cleaned>.+?)\s+-\s+[0-9]+\s*$",
@"^\s*(?<cleaned>.+?)(([-._ ](trailer|sample))|-(scene|clip|behindthescenes|deleted|deletedscene|featurette|short|interview|other|extra))$"
- };
+ ];
- SubtitleFileExtensions = new[]
- {
+ SubtitleFileExtensions =
+ [
".ass",
".mks",
".sami",
@@ -171,27 +171,29 @@ namespace Emby.Naming.Common
".sub",
".sup",
".vtt",
- };
+ ];
- LyricFileExtensions = new[]
- {
+ LyricFileExtensions =
+ [
".lrc",
".elrc",
".txt"
- };
+ ];
- AlbumStackingPrefixes = new[]
- {
+ AlbumStackingPrefixes =
+ [
"cd",
"digital media",
"disc",
"disk",
"vol",
- "volume"
- };
+ "volume",
+ "part",
+ "act"
+ ];
- ArtistSubfolders = new[]
- {
+ ArtistSubfolders =
+ [
"albums",
"broadcasts",
"bootlegs",
@@ -206,10 +208,10 @@ namespace Emby.Naming.Common
"soundtracks",
"spokenwords",
"streets"
- };
+ ];
- AudioFileExtensions = new[]
- {
+ AudioFileExtensions =
+ [
".669",
".3gp",
".aa",
@@ -239,6 +241,7 @@ namespace Emby.Naming.Common
".dts",
".dvf",
".eac3",
+ ".ec3",
".far",
".flac",
".gdm",
@@ -289,33 +292,33 @@ namespace Emby.Naming.Common
".xm",
".xsp",
".ymf"
- };
+ ];
- MediaFlagDelimiters = new[]
- {
+ MediaFlagDelimiters =
+ [
'.'
- };
+ ];
- MediaForcedFlags = new[]
- {
+ MediaForcedFlags =
+ [
"foreign",
"forced"
- };
+ ];
- MediaDefaultFlags = new[]
- {
+ MediaDefaultFlags =
+ [
"default"
- };
+ ];
- MediaHearingImpairedFlags = new[]
- {
+ MediaHearingImpairedFlags =
+ [
"cc",
"hi",
"sdh"
- };
+ ];
- EpisodeExpressions = new[]
- {
+ EpisodeExpressions =
+ [
// *** Begin Kodi Standard Naming
// <!-- foo.s01.e01, foo.s01_e01, S01E02 foo, S01 - E02 -->
new EpisodeExpression(@".*(\\|\/)(?<seriesname>((?![Ss]([0-9]+)[][ ._-]*[Ee]([0-9]+))[^\\\/])*)?[Ss](?<seasonnumber>[0-9]+)[][ ._-]*[Ee](?<epnumber>[0-9]+)([^\\/]*)$")
@@ -328,23 +331,23 @@ namespace Emby.Naming.Common
new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"),
new EpisodeExpression("(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true)
{
- DateTimeFormats = new[]
- {
+ DateTimeFormats =
+ [
"yyyy.MM.dd",
"yyyy-MM-dd",
"yyyy_MM_dd",
"yyyy MM dd"
- }
+ ]
},
new EpisodeExpression("(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true)
{
- DateTimeFormats = new[]
- {
+ DateTimeFormats =
+ [
"dd.MM.yyyy",
"dd-MM-yyyy",
"dd_MM_yyyy",
"dd MM yyyy"
- }
+ ]
},
// This isn't a Kodi naming rule, but the expression below causes false episode numbers for
@@ -476,10 +479,10 @@ namespace Emby.Naming.Common
{
IsNamed = true
},
- };
+ ];
- VideoExtraRules = new[]
- {
+ VideoExtraRules =
+ [
new ExtraRule(
ExtraType.Trailer,
ExtraRuleType.DirectoryName,
@@ -571,6 +574,18 @@ namespace Emby.Naming.Common
MediaType.Video),
new ExtraRule(
+ ExtraType.Sample,
+ ExtraRuleType.Filename,
+ "sample",
+ MediaType.Video),
+
+ new ExtraRule(
+ ExtraType.ThemeSong,
+ ExtraRuleType.Filename,
+ "theme",
+ MediaType.Audio),
+
+ new ExtraRule(
ExtraType.Trailer,
ExtraRuleType.Suffix,
"-trailer",
@@ -591,13 +606,7 @@ namespace Emby.Naming.Common
new ExtraRule(
ExtraType.Trailer,
ExtraRuleType.Suffix,
- " trailer",
- MediaType.Video),
-
- new ExtraRule(
- ExtraType.Sample,
- ExtraRuleType.Filename,
- "sample",
+ "- trailer",
MediaType.Video),
new ExtraRule(
@@ -621,16 +630,10 @@ namespace Emby.Naming.Common
new ExtraRule(
ExtraType.Sample,
ExtraRuleType.Suffix,
- " sample",
+ "- sample",
MediaType.Video),
new ExtraRule(
- ExtraType.ThemeSong,
- ExtraRuleType.Filename,
- "theme",
- MediaType.Audio),
-
- new ExtraRule(
ExtraType.Scene,
ExtraRuleType.Suffix,
"-scene",
@@ -689,14 +692,14 @@ namespace Emby.Naming.Common
ExtraRuleType.Suffix,
"-other",
MediaType.Video)
- };
+ ];
AllExtrasTypesFolderNames = VideoExtraRules
.Where(i => i.RuleType == ExtraRuleType.DirectoryName)
.ToDictionary(i => i.Token, i => i.ExtraType, StringComparer.OrdinalIgnoreCase);
- Format3DRules = new[]
- {
+ Format3DRules =
+ [
// Kodi rules:
new Format3DRule(
precedingToken: "3d",
@@ -723,10 +726,10 @@ namespace Emby.Naming.Common
new Format3DRule("tab"),
new Format3DRule("sbs3d"),
new Format3DRule("mvc")
- };
+ ];
- AudioBookPartsExpressions = new[]
- {
+ AudioBookPartsExpressions =
+ [
// Detect specified chapters, like CH 01
@"ch(?:apter)?[\s_-]?(?<chapter>[0-9]+)",
// Detect specified parts, like Part 02
@@ -739,14 +742,14 @@ namespace Emby.Naming.Common
"(?<chapter>[0-9]+)_(?<part>[0-9]+)",
// Some audiobooks are ripped from cd's, and will be named by disk number.
@"dis(?:c|k)[\s_-]?(?<chapter>[0-9]+)"
- };
+ ];
- AudioBookNamesExpressions = new[]
- {
+ AudioBookNamesExpressions =
+ [
// Detect year usually in brackets after name Batman (2020)
@"^(?<name>.+?)\s*\(\s*(?<year>[0-9]{4})\s*\)\s*$",
@"^\s*(?<name>[^ ].*?)\s*$"
- };
+ ];
MultipleEpisodeExpressions = new[]
{
@@ -886,12 +889,12 @@ namespace Emby.Naming.Common
/// <summary>
/// Gets list of clean datetime regular expressions.
/// </summary>
- public Regex[] CleanDateTimeRegexes { get; private set; } = Array.Empty<Regex>();
+ public Regex[] CleanDateTimeRegexes { get; private set; } = [];
/// <summary>
/// Gets list of clean string regular expressions.
/// </summary>
- public Regex[] CleanStringRegexes { get; private set; } = Array.Empty<Regex>();
+ public Regex[] CleanStringRegexes { get; private set; } = [];
/// <summary>
/// Compiles raw regex strings into regexes.
diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj
index 20b32f3a6..b84c96116 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>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.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 7a01b02f3..3461b3c0d 100644
--- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs
+++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
@@ -97,14 +97,18 @@ namespace Emby.Naming.ExternalFiles
if (culture is not null && pathInfo.Language is null)
{
- pathInfo.Language = culture.ThreeLetterISOLanguageName;
+ pathInfo.Language = culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase)
+ ? culture.Name
+ : culture.ThreeLetterISOLanguageName;
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
}
else if (culture is not null && pathInfo.Language == "hin")
{
// Hindi language code "hi" collides with a hearing impaired flag - use as Hindi only if no other language is set
pathInfo.IsHearingImpaired = true;
- pathInfo.Language = culture.ThreeLetterISOLanguageName;
+ pathInfo.Language = culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase)
+ ? culture.Name
+ : culture.ThreeLetterISOLanguageName;
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
}
else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Equals(s, StringComparison.OrdinalIgnoreCase)))
diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs
index 98ee1e4b8..72adfb2d9 100644
--- a/Emby.Naming/TV/SeasonPathParser.cs
+++ b/Emby.Naming/TV/SeasonPathParser.cs
@@ -10,12 +10,17 @@ namespace Emby.Naming.TV
/// </summary>
public static partial class SeasonPathParser
{
- [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$")]
+ private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
+
+ [GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPre();
- [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")]
+ [GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPost();
+ [GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
+ private static partial Regex SeasonPrefix();
+
/// <summary>
/// Attempts to parse season number from path.
/// </summary>
@@ -56,44 +61,34 @@ namespace Emby.Naming.TV
bool supportSpecialAliases,
bool supportNumericSeasonFolders)
{
- string filename = Path.GetFileName(path);
- filename = Regex.Replace(filename, "[ ._-]", string.Empty);
+ var fileName = Path.GetFileName(path);
- if (parentFolderName is not null)
+ var seasonPrefixMatch = SeasonPrefix().Match(fileName);
+ if (seasonPrefixMatch.Success &&
+ int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{
- parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty);
- filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase);
+ return (val, true);
}
- if (supportSpecialAliases)
- {
- if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase))
- {
- return (0, true);
- }
+ string filename = CleanNameRegex.Replace(fileName, string.Empty);
- if (string.Equals(filename, "extras", StringComparison.OrdinalIgnoreCase))
- {
- return (0, true);
- }
+ if (parentFolderName is not null)
+ {
+ var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
+ filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
}
- if (supportNumericSeasonFolders)
+ if (supportSpecialAliases &&
+ (filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
+ filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
{
- if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
- {
- return (val, true);
- }
+ return (0, true);
}
- if (filename.StartsWith('s'))
+ if (supportNumericSeasonFolders &&
+ int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
{
- var testFilename = filename.AsSpan()[1..];
-
- if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
- {
- return (val, true);
- }
+ return (val, true);
}
var preMatch = ProcessPre().Match(filename);
@@ -113,8 +108,10 @@ namespace Emby.Naming.TV
var numberString = match.Groups["seasonnumber"];
if (numberString.Success)
{
- var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture);
- return (seasonNumber, true);
+ if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber))
+ {
+ return (seasonNumber, true);
+ }
}
return (null, false);
diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs
index c955b8a0d..0b7309bae 100644
--- a/Emby.Naming/TV/SeriesResolver.cs
+++ b/Emby.Naming/TV/SeriesResolver.cs
@@ -18,6 +18,13 @@ namespace Emby.Naming.TV
private static partial Regex SeriesNameRegex();
/// <summary>
+ /// Regex that matches titles with year in parentheses. Captures the title (which may be
+ /// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
+ /// </summary>
+ [GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")]
+ private static partial Regex TitleWithYearRegex();
+
+ /// <summary>
/// Resolve information about series from path.
/// </summary>
/// <param name="options"><see cref="NamingOptions"/> object passed to <see cref="SeriesPathParser"/>.</param>
@@ -27,6 +34,20 @@ namespace Emby.Naming.TV
{
string seriesName = Path.GetFileName(path);
+ // First check if the filename matches a title with year pattern (handles numeric titles)
+ if (!string.IsNullOrEmpty(seriesName))
+ {
+ var titleWithYearMatch = TitleWithYearRegex().Match(seriesName);
+ if (titleWithYearMatch.Success)
+ {
+ seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
+ return new SeriesInfo(path)
+ {
+ Name = seriesName
+ };
+ }
+ }
+
SeriesPathParserResult result = SeriesPathParser.Parse(options, path);
if (result.Success)
{
diff --git a/Emby.Naming/Video/ExtraRuleResolver.cs b/Emby.Naming/Video/ExtraRuleResolver.cs
index 528906589..2e0caa612 100644
--- a/Emby.Naming/Video/ExtraRuleResolver.cs
+++ b/Emby.Naming/Video/ExtraRuleResolver.cs
@@ -22,67 +22,45 @@ namespace Emby.Naming.Video
/// <returns>Returns <see cref="ExtraResult"/> object.</returns>
public static ExtraResult GetExtraInfo(string path, NamingOptions namingOptions, string? libraryRoot = "")
{
- var result = new ExtraResult();
+ ExtraResult result = new ExtraResult();
- for (var i = 0; i < namingOptions.VideoExtraRules.Length; i++)
+ bool isAudioFile = AudioFileParser.IsAudioFile(path, namingOptions);
+ bool isVideoFile = VideoResolver.IsVideoFile(path, namingOptions);
+
+ ReadOnlySpan<char> pathSpan = path.AsSpan();
+ ReadOnlySpan<char> fileName = Path.GetFileName(pathSpan);
+ ReadOnlySpan<char> fileNameWithoutExtension = Path.GetFileNameWithoutExtension(pathSpan);
+ // Trim the digits from the end of the filename so we can recognize things like -trailer2
+ ReadOnlySpan<char> trimmedFileNameWithoutExtension = fileNameWithoutExtension.TrimEnd(_digits);
+ ReadOnlySpan<char> directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan));
+ string fullDirectory = Path.GetDirectoryName(pathSpan).ToString();
+
+ foreach (ExtraRule rule in namingOptions.VideoExtraRules)
{
- var rule = namingOptions.VideoExtraRules[i];
- if ((rule.MediaType == MediaType.Audio && !AudioFileParser.IsAudioFile(path, namingOptions))
- || (rule.MediaType == MediaType.Video && !VideoResolver.IsVideoFile(path, namingOptions)))
+ if ((rule.MediaType == MediaType.Audio && !isAudioFile)
+ || (rule.MediaType == MediaType.Video && !isVideoFile))
{
continue;
}
- var pathSpan = path.AsSpan();
- if (rule.RuleType == ExtraRuleType.Filename)
+ bool isMatch = rule.RuleType switch
{
- var filename = Path.GetFileNameWithoutExtension(pathSpan);
+ ExtraRuleType.Filename => fileNameWithoutExtension.Equals(rule.Token, StringComparison.OrdinalIgnoreCase),
+ ExtraRuleType.Suffix => trimmedFileNameWithoutExtension.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase),
+ ExtraRuleType.Regex => Regex.IsMatch(fileName, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled),
+ ExtraRuleType.DirectoryName => directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(fullDirectory, libraryRoot, StringComparison.OrdinalIgnoreCase),
+ _ => false,
+ };
- if (filename.Equals(rule.Token, StringComparison.OrdinalIgnoreCase))
- {
- result.ExtraType = rule.ExtraType;
- result.Rule = rule;
- }
- }
- else if (rule.RuleType == ExtraRuleType.Suffix)
+ if (!isMatch)
{
- // Trim the digits from the end of the filename so we can recognize things like -trailer2
- var filename = Path.GetFileNameWithoutExtension(pathSpan).TrimEnd(_digits);
-
- if (filename.EndsWith(rule.Token, StringComparison.OrdinalIgnoreCase))
- {
- result.ExtraType = rule.ExtraType;
- result.Rule = rule;
- }
- }
- else if (rule.RuleType == ExtraRuleType.Regex)
- {
- var filename = Path.GetFileName(path.AsSpan());
-
- var isMatch = Regex.IsMatch(filename, rule.Token, RegexOptions.IgnoreCase | RegexOptions.Compiled);
-
- if (isMatch)
- {
- result.ExtraType = rule.ExtraType;
- result.Rule = rule;
- }
- }
- else if (rule.RuleType == ExtraRuleType.DirectoryName)
- {
- var directoryName = Path.GetFileName(Path.GetDirectoryName(pathSpan));
- string fullDirectory = Path.GetDirectoryName(pathSpan).ToString();
- if (directoryName.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)
- && !string.Equals(fullDirectory, libraryRoot, StringComparison.OrdinalIgnoreCase))
- {
- result.ExtraType = rule.ExtraType;
- result.Rule = rule;
- }
+ continue;
}
- if (result.ExtraType is not null)
- {
- return result;
- }
+ result.ExtraType = rule.ExtraType;
+ result.Rule = rule;
+ return result;
}
return result;
diff --git a/Emby.Naming/Video/StackResolver.cs b/Emby.Naming/Video/StackResolver.cs
index 8119a0267..6a07561a0 100644
--- a/Emby.Naming/Video/StackResolver.cs
+++ b/Emby.Naming/Video/StackResolver.cs
@@ -132,7 +132,7 @@ namespace Emby.Naming.Video
}
}
- private class StackMetadata
+ private sealed class StackMetadata
{
public StackMetadata(bool isDirectory, bool isNumerical, string partType)
{
diff --git a/Emby.Photos/PhotoProvider.cs b/Emby.Photos/PhotoProvider.cs
index ac6c41ca5..e24c067d6 100644
--- a/Emby.Photos/PhotoProvider.cs
+++ b/Emby.Photos/PhotoProvider.cs
@@ -49,7 +49,7 @@ public class PhotoProvider : ICustomMetadataProvider<Photo>, IForcedProvider, IH
if (item.IsFileProtocol)
{
var file = directoryService.GetFile(item.Path);
- return file is not null && file.LastWriteTimeUtc != item.DateModified;
+ return file is not null && item.HasChanged(file.LastWriteTimeUtc);
}
return false;
@@ -108,7 +108,7 @@ public class PhotoProvider : ICustomMetadataProvider<Photo>, IForcedProvider, IH
var dateTaken = image.ImageTag.DateTime;
if (dateTaken.HasValue)
{
- item.DateCreated = dateTaken.Value;
+ item.DateCreated = dateTaken.Value.ToUniversalTime();
item.PremiereDate = dateTaken.Value;
item.ProductionYear = dateTaken.Value.Year;
}
diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
index f0cca9efd..de722332a 100644
--- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
+++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
@@ -1,5 +1,8 @@
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Linq;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
namespace Emby.Server.Implementations.AppBase
@@ -30,7 +33,6 @@ namespace Emby.Server.Implementations.AppBase
ConfigurationDirectoryPath = configurationDirectoryPath;
CachePath = cacheDirectoryPath;
WebPath = webDirectoryPath;
-
DataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName;
}
@@ -75,5 +77,57 @@ namespace Emby.Server.Implementations.AppBase
/// <inheritdoc />
public string TrickplayPath => Path.Combine(DataPath, "trickplay");
+
+ /// <inheritdoc />
+ public string BackupPath => Path.Combine(DataPath, "backups");
+
+ /// <inheritdoc />
+ public virtual void MakeSanityCheckOrThrow()
+ {
+ CreateAndCheckMarker(ConfigurationDirectoryPath, "config");
+ CreateAndCheckMarker(LogDirectoryPath, "log");
+ CreateAndCheckMarker(PluginsPath, "plugin");
+ CreateAndCheckMarker(ProgramDataPath, "data");
+ CreateAndCheckMarker(CachePath, "cache");
+ CreateAndCheckMarker(DataPath, "data");
+ }
+
+ /// <inheritdoc />
+ public void CreateAndCheckMarker(string path, string markerName, bool recursive = false)
+ {
+ Directory.CreateDirectory(path);
+
+ CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive);
+ }
+
+ private IEnumerable<string> GetMarkers(string path, bool recursive = false)
+ {
+ return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly);
+ }
+
+ private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
+ {
+ string? otherMarkers = null;
+ try
+ {
+ otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase));
+ }
+ catch
+ {
+ // Error while checking for marker files, assume none exist and keep going
+ // TODO: add some logging
+ }
+
+ if (otherMarkers is not null)
+ {
+ throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}.");
+ }
+
+ var markerPath = Path.Combine(path, markerName);
+ if (!File.Exists(markerPath))
+ {
+ FileHelper.CreateEmpty(markerPath);
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs
index 9bc3a0204..81ef0e5f9 100644
--- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs
+++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs
@@ -227,6 +227,7 @@ namespace Emby.Server.Implementations.AppBase
Logger.LogInformation("Setting cache path: {Path}", cachePath);
((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath;
+ CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache");
}
/// <summary>
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index 5bb75e2b9..cbb0f6c56 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -15,6 +15,7 @@ using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
using Emby.Naming.Common;
using Emby.Photos;
+using Emby.Server.Implementations.Chapters;
using Emby.Server.Implementations.Collections;
using Emby.Server.Implementations.Configuration;
using Emby.Server.Implementations.Cryptography;
@@ -35,13 +36,14 @@ using Emby.Server.Implementations.SyncPlay;
using Emby.Server.Implementations.TV;
using Emby.Server.Implementations.Updates;
using Jellyfin.Api.Helpers;
-using Jellyfin.Database.Implementations;
using Jellyfin.Drawing;
using Jellyfin.MediaEncoding.Hls.Playlist;
using Jellyfin.Networking.Manager;
using Jellyfin.Networking.Udp;
+using Jellyfin.Server.Implementations.FullSystemBackup;
using Jellyfin.Server.Implementations.Item;
using Jellyfin.Server.Implementations.MediaSegments;
+using Jellyfin.Server.Implementations.SystemBackupService;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Events;
@@ -57,11 +59,14 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LibraryTaskScheduler;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
@@ -92,7 +97,6 @@ using MediaBrowser.Providers.Subtitles;
using MediaBrowser.XbmcMetadata.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -267,6 +271,8 @@ namespace Emby.Server.Implementations
? Environment.MachineName
: ConfigurationManager.Configuration.ServerName;
+ public string RestoreBackupPath { get; set; }
+
public string ExpandVirtualPath(string path)
{
if (path is null)
@@ -471,6 +477,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IApplicationHost>(this);
serviceCollection.AddSingleton<IPluginManager>(_pluginManager);
serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
+ serviceCollection.AddSingleton<IBackupService, BackupService>();
serviceCollection.AddSingleton<IFileSystem, ManagedFileSystem>();
serviceCollection.AddSingleton<IShortcutHandler, MbLinkShortcutHandler>();
@@ -511,6 +518,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IMediaEncoder, MediaBrowser.MediaEncoding.Encoder.MediaEncoder>();
serviceCollection.AddSingleton<EncodingHelper>();
serviceCollection.AddSingleton<IPathManager, PathManager>();
+ serviceCollection.AddSingleton<IExternalDataManager, ExternalDataManager>();
// TODO: Refactor to eliminate the circular dependencies here so that Lazy<T> isn't required
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
@@ -545,6 +553,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ISessionManager, SessionManager>();
serviceCollection.AddSingleton<ICollectionManager, CollectionManager>();
+ serviceCollection.AddSingleton<ILimitedConcurrencyLibraryScheduler, LimitedConcurrencyLibraryScheduler>();
serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
@@ -552,13 +561,14 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IUserViewManager, UserViewManager>();
- serviceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>();
+ serviceCollection.AddSingleton<IChapterManager, ChapterManager>();
serviceCollection.AddSingleton<IAuthService, AuthService>();
serviceCollection.AddSingleton<IQuickConnect, QuickConnectManager>();
serviceCollection.AddSingleton<ISubtitleParser, SubtitleEditParser>();
serviceCollection.AddSingleton<ISubtitleEncoder, SubtitleEncoder>();
+ serviceCollection.AddSingleton<IKeyframeManager, KeyframeManager>();
serviceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
@@ -579,21 +589,6 @@ namespace Emby.Server.Implementations
/// <returns>A task representing the service initialization operation.</returns>
public async Task InitializeServices(IConfiguration startupConfig)
{
- var factory = Resolve<IDbContextFactory<JellyfinDbContext>>();
- var provider = Resolve<IJellyfinDatabaseProvider>();
- provider.DbContextFactory = factory;
-
- var jellyfinDb = await factory.CreateDbContextAsync().ConfigureAwait(false);
- await using (jellyfinDb.ConfigureAwait(false))
- {
- if ((await jellyfinDb.Database.GetPendingMigrationsAsync().ConfigureAwait(false)).Any())
- {
- Logger.LogInformation("There are pending EFCore migrations in the database. Applying... (This may take a while, do not stop Jellyfin)");
- await jellyfinDb.Database.MigrateAsync().ConfigureAwait(false);
- Logger.LogInformation("EFCore migrations applied successfully");
- }
- }
-
var localizationManager = (LocalizationManager)Resolve<ILocalizationManager>();
await localizationManager.LoadAll().ConfigureAwait(false);
@@ -641,24 +636,26 @@ namespace Emby.Server.Implementations
private void SetStaticProperties()
{
// For now there's no real way to inject these properly
- BaseItem.Logger = Resolve<ILogger<BaseItem>>();
+ BaseItem.ChapterManager = Resolve<IChapterManager>();
+ BaseItem.ChannelManager = Resolve<IChannelManager>();
BaseItem.ConfigurationManager = ConfigurationManager;
+ BaseItem.FileSystem = Resolve<IFileSystem>();
+ BaseItem.ItemRepository = Resolve<IItemRepository>();
BaseItem.LibraryManager = Resolve<ILibraryManager>();
- BaseItem.ProviderManager = Resolve<IProviderManager>();
BaseItem.LocalizationManager = Resolve<ILocalizationManager>();
- BaseItem.ItemRepository = Resolve<IItemRepository>();
- BaseItem.ChapterRepository = Resolve<IChapterRepository>();
- BaseItem.FileSystem = Resolve<IFileSystem>();
- BaseItem.UserDataManager = Resolve<IUserDataManager>();
- BaseItem.ChannelManager = Resolve<IChannelManager>();
- Video.RecordingsManager = Resolve<IRecordingsManager>();
- Folder.UserViewManager = Resolve<IUserViewManager>();
- UserView.TVSeriesManager = Resolve<ITVSeriesManager>();
- UserView.CollectionManager = Resolve<ICollectionManager>();
- BaseItem.MediaSourceManager = Resolve<IMediaSourceManager>();
+ BaseItem.Logger = Resolve<ILogger<BaseItem>>();
BaseItem.MediaSegmentManager = Resolve<IMediaSegmentManager>();
+ BaseItem.MediaSourceManager = Resolve<IMediaSourceManager>();
+ BaseItem.ProviderManager = Resolve<IProviderManager>();
+ BaseItem.UserDataManager = Resolve<IUserDataManager>();
CollectionFolder.XmlSerializer = _xmlSerializer;
CollectionFolder.ApplicationHost = this;
+ Folder.UserViewManager = Resolve<IUserViewManager>();
+ Folder.CollectionManager = Resolve<ICollectionManager>();
+ Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>();
+ Episode.MediaEncoder = Resolve<IMediaEncoder>();
+ UserView.TVSeriesManager = Resolve<ITVSeriesManager>();
+ Video.RecordingsManager = Resolve<IRecordingsManager>();
}
/// <summary>
diff --git a/Emby.Server.Implementations/Chapters/ChapterManager.cs b/Emby.Server.Implementations/Chapters/ChapterManager.cs
new file mode 100644
index 000000000..d09ed30ae
--- /dev/null
+++ b/Emby.Server.Implementations/Chapters/ChapterManager.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Extensions;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.Chapters;
+
+/// <summary>
+/// The chapter manager.
+/// </summary>
+public class ChapterManager : IChapterManager
+{
+ private readonly IFileSystem _fileSystem;
+ private readonly ILogger<ChapterManager> _logger;
+ private readonly IMediaEncoder _encoder;
+ private readonly IChapterRepository _chapterRepository;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IPathManager _pathManager;
+
+ /// <summary>
+ /// The first chapter ticks.
+ /// </summary>
+ private static readonly long _firstChapterTicks = TimeSpan.FromSeconds(15).Ticks;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ChapterManager"/> class.
+ /// </summary>
+ /// <param name="logger">The <see cref="ILogger{ChapterManager}"/>.</param>
+ /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
+ /// <param name="encoder">The <see cref="IMediaEncoder"/>.</param>
+ /// <param name="chapterRepository">The <see cref="IChapterRepository"/>.</param>
+ /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
+ /// <param name="pathManager">The <see cref="IPathManager"/>.</param>
+ public ChapterManager(
+ ILogger<ChapterManager> logger,
+ IFileSystem fileSystem,
+ IMediaEncoder encoder,
+ IChapterRepository chapterRepository,
+ ILibraryManager libraryManager,
+ IPathManager pathManager)
+ {
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _encoder = encoder;
+ _chapterRepository = chapterRepository;
+ _libraryManager = libraryManager;
+ _pathManager = pathManager;
+ }
+
+ /// <summary>
+ /// Determines whether [is eligible for chapter image extraction] [the specified video].
+ /// </summary>
+ /// <param name="video">The video.</param>
+ /// <param name="libraryOptions">The library options for the video.</param>
+ /// <returns><c>true</c> if [is eligible for chapter image extraction] [the specified video]; otherwise, <c>false</c>.</returns>
+ private bool IsEligibleForChapterImageExtraction(Video video, LibraryOptions libraryOptions)
+ {
+ if (video.IsPlaceHolder)
+ {
+ return false;
+ }
+
+ if (libraryOptions is null || !libraryOptions.EnableChapterImageExtraction)
+ {
+ return false;
+ }
+
+ if (video.IsShortcut)
+ {
+ return false;
+ }
+
+ if (!video.IsCompleteMedia)
+ {
+ return false;
+ }
+
+ // Can't extract images if there are no video streams
+ return video.DefaultVideoStreamIndex.HasValue;
+ }
+
+ private long GetAverageDurationBetweenChapters(IReadOnlyList<ChapterInfo> chapters)
+ {
+ if (chapters.Count < 2)
+ {
+ return 0;
+ }
+
+ long sum = 0;
+ for (int i = 1; i < chapters.Count; i++)
+ {
+ sum += chapters[i].StartPositionTicks - chapters[i - 1].StartPositionTicks;
+ }
+
+ return sum / chapters.Count;
+ }
+
+ /// <inheritdoc />
+ public async Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken)
+ {
+ if (chapters.Count == 0)
+ {
+ return true;
+ }
+
+ var libraryOptions = _libraryManager.GetLibraryOptions(video);
+
+ if (!IsEligibleForChapterImageExtraction(video, libraryOptions))
+ {
+ extractImages = false;
+ }
+
+ var averageChapterDuration = GetAverageDurationBetweenChapters(chapters);
+ var threshold = TimeSpan.FromSeconds(1).Ticks;
+ if (averageChapterDuration < threshold)
+ {
+ _logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold);
+ extractImages = false;
+ }
+
+ var success = true;
+ var changesMade = false;
+
+ var runtimeTicks = video.RunTimeTicks ?? 0;
+
+ var currentImages = GetSavedChapterImages(video, directoryService);
+
+ foreach (var chapter in chapters)
+ {
+ if (chapter.StartPositionTicks >= runtimeTicks)
+ {
+ _logger.LogInformation("Stopping chapter extraction for {0} because a chapter was found with a position greater than the runtime.", video.Name);
+ break;
+ }
+
+ var path = _pathManager.GetChapterImagePath(video, chapter.StartPositionTicks);
+
+ if (!currentImages.Contains(path, StringComparison.OrdinalIgnoreCase))
+ {
+ if (extractImages)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ // Add some time for the first chapter to make sure we don't end up with a black image
+ var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(_firstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks);
+
+ var inputPath = video.Path;
+ var directoryPath = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directoryPath))
+ {
+ Directory.CreateDirectory(directoryPath);
+ }
+
+ var container = video.Container;
+ var mediaSource = new MediaSourceInfo
+ {
+ VideoType = video.VideoType,
+ IsoType = video.IsoType,
+ Protocol = video.PathProtocol ?? MediaProtocol.File,
+ };
+
+ _logger.LogInformation("Extracting chapter image for {Name} at {Path}", video.Name, inputPath);
+ var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
+ File.Copy(tempFile, path, true);
+
+ try
+ {
+ _fileSystem.DeleteFile(tempFile);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting temporary chapter image encoding file {Path}", tempFile);
+ }
+
+ chapter.ImagePath = path;
+ chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
+ changesMade = true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error extracting chapter images for {0}", string.Join(',', video.Path));
+ success = false;
+ break;
+ }
+ }
+ else if (!string.IsNullOrEmpty(chapter.ImagePath))
+ {
+ chapter.ImagePath = null;
+ changesMade = true;
+ }
+ }
+ else if (!string.Equals(path, chapter.ImagePath, StringComparison.OrdinalIgnoreCase))
+ {
+ chapter.ImagePath = path;
+ chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
+ changesMade = true;
+ }
+ else if (libraryOptions?.EnableChapterImageExtraction != true)
+ {
+ // We have an image for the current chapter but the user has disabled chapter image extraction -> delete this chapter's image
+ chapter.ImagePath = null;
+ changesMade = true;
+ }
+ }
+
+ if (saveChapters && changesMade)
+ {
+ SaveChapters(video, chapters);
+ }
+
+ DeleteDeadImages(currentImages, chapters);
+
+ return success;
+ }
+
+ /// <inheritdoc />
+ public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
+ {
+ // Remove any chapters that are outside of the runtime of the video
+ var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList();
+ _chapterRepository.SaveChapters(video.Id, validChapters);
+ }
+
+ /// <inheritdoc />
+ public ChapterInfo? GetChapter(Guid baseItemId, int index)
+ {
+ return _chapterRepository.GetChapter(baseItemId, index);
+ }
+
+ /// <inheritdoc />
+ public IReadOnlyList<ChapterInfo> GetChapters(Guid baseItemId)
+ {
+ return _chapterRepository.GetChapters(baseItemId);
+ }
+
+ /// <inheritdoc />
+ public async Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken)
+ {
+ await _chapterRepository.DeleteChaptersAsync(itemId, cancellationToken).ConfigureAwait(false);
+ }
+
+ private IReadOnlyList<string> GetSavedChapterImages(Video video, IDirectoryService directoryService)
+ {
+ var path = _pathManager.GetChapterImageFolderPath(video);
+ if (!Directory.Exists(path))
+ {
+ return [];
+ }
+
+ try
+ {
+ return directoryService.GetFilePaths(path);
+ }
+ catch (IOException)
+ {
+ return [];
+ }
+ }
+
+ private void DeleteDeadImages(IEnumerable<string> images, IEnumerable<ChapterInfo> chapters)
+ {
+ var existingImages = chapters.Select(i => i.ImagePath).Where(i => !string.IsNullOrEmpty(i));
+ var deadImages = images
+ .Except(existingImages, StringComparer.OrdinalIgnoreCase)
+ .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i.AsSpan()), StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ foreach (var image in deadImages)
+ {
+ _logger.LogDebug("Deleting dead chapter image {Path}", image);
+
+ try
+ {
+ _fileSystem.DeleteFile(image!);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting {Path}.", image);
+ }
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
index 60f515f24..a320a774c 100644
--- a/Emby.Server.Implementations/Collections/CollectionManager.cs
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -95,7 +95,7 @@ namespace Emby.Server.Implementations.Collections
var libraryOptions = new LibraryOptions
{
- PathInfos = new[] { new MediaPathInfo(path) },
+ PathInfos = [new MediaPathInfo(path)],
EnableRealtimeMonitor = false,
SaveLocalMetadata = true
};
@@ -104,6 +104,8 @@ namespace Emby.Server.Implementations.Collections
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
+ _libraryManager.RootFolder.Children = null;
+
return FindFolders(path).First();
}
@@ -150,15 +152,15 @@ namespace Emby.Server.Implementations.Collections
try
{
- Directory.CreateDirectory(path);
-
+ var info = Directory.CreateDirectory(path);
var collection = new BoxSet
{
Name = name,
Path = path,
IsLocked = options.IsLocked,
ProviderIds = options.ProviderIds,
- DateCreated = DateTime.UtcNow
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc
};
parentFolder.AddChild(collection);
diff --git a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
index 9a80eafe5..676bb7f81 100644
--- a/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
+++ b/Emby.Server.Implementations/Data/CleanDatabaseScheduledTask.cs
@@ -48,7 +48,9 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
var numComplete = 0;
var numItems = itemIds.Count + 1;
- _logger.LogDebug("Cleaning {Number} items with dead parent links", numItems);
+ _logger.LogDebug("Cleaning {Number} items with dead parents", numItems);
+
+ IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
foreach (var itemId in itemIds)
{
@@ -61,33 +63,28 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
foreach (var mediaSource in item.GetMediaSources(false))
{
- // Delete extracted subtitles
- try
+ // Delete extracted data
+ var mediaSourceItem = _libraryManager.GetItemById(mediaSource.Id);
+ if (mediaSourceItem is null)
{
- var subtitleFolder = _pathManager.GetSubtitleFolderPath(mediaSource.Id);
- if (Directory.Exists(subtitleFolder))
- {
- Directory.Delete(subtitleFolder, true);
- }
- }
- catch (Exception e)
- {
- _logger.LogWarning("Failed to remove subtitle cache folder for {Item}: {Exception}", item.Id, e.Message);
+ continue;
}
- // Delete extracted attachments
- try
+ var extractedDataFolders = _pathManager.GetExtractedDataPaths(mediaSourceItem);
+ foreach (var folder in extractedDataFolders)
{
- var attachmentFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
- if (Directory.Exists(attachmentFolder))
+ if (Directory.Exists(folder))
{
- Directory.Delete(attachmentFolder, true);
+ try
+ {
+ Directory.Delete(folder, true);
+ }
+ catch (Exception e)
+ {
+ _logger.LogWarning("Failed to remove {Folder}: {Exception}", folder, e.Message);
+ }
}
}
- catch (Exception e)
- {
- _logger.LogWarning("Failed to remove attachment cache folder for {Item}: {Exception}", item.Id, e.Message);
- }
}
// Delete item
@@ -100,9 +97,10 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
numComplete++;
double percent = numComplete;
percent /= numItems;
- progress.Report(percent * 100);
+ subProgress.Report(percent * 100);
}
+ subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
@@ -110,7 +108,9 @@ public class CleanDatabaseScheduledTask : ILibraryPostScanTask
await using (transaction.ConfigureAwait(false))
{
await context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ subProgress.Report(50);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ subProgress.Report(100);
}
}
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 5b0fc9ef3..c5dc3b054 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Frozen;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@@ -17,7 +18,6 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Trickplay;
@@ -38,6 +38,77 @@ namespace Emby.Server.Implementations.Dto
{
public class DtoService : IDtoService
{
+ private static readonly FrozenDictionary<BaseItemKind, BaseItemKind[]> _relatedItemKinds = new Dictionary<BaseItemKind, BaseItemKind[]>
+ {
+ {
+ BaseItemKind.Genre, [
+ BaseItemKind.Audio,
+ BaseItemKind.Episode,
+ BaseItemKind.Movie,
+ BaseItemKind.LiveTvProgram,
+ BaseItemKind.MusicAlbum,
+ BaseItemKind.MusicArtist,
+ BaseItemKind.MusicVideo,
+ BaseItemKind.Series,
+ BaseItemKind.Trailer
+ ]
+ },
+ {
+ BaseItemKind.MusicArtist, [
+ BaseItemKind.Audio,
+ BaseItemKind.MusicAlbum,
+ BaseItemKind.MusicVideo
+ ]
+ },
+ {
+ BaseItemKind.MusicGenre, [
+ BaseItemKind.Audio,
+ BaseItemKind.MusicAlbum,
+ BaseItemKind.MusicArtist,
+ BaseItemKind.MusicVideo
+ ]
+ },
+ {
+ BaseItemKind.Person, [
+ BaseItemKind.Audio,
+ BaseItemKind.Episode,
+ BaseItemKind.Movie,
+ BaseItemKind.LiveTvProgram,
+ BaseItemKind.MusicAlbum,
+ BaseItemKind.MusicArtist,
+ BaseItemKind.MusicVideo,
+ BaseItemKind.Series,
+ BaseItemKind.Trailer
+ ]
+ },
+ {
+ BaseItemKind.Studio, [
+ BaseItemKind.Audio,
+ BaseItemKind.Episode,
+ BaseItemKind.Movie,
+ BaseItemKind.LiveTvProgram,
+ BaseItemKind.MusicAlbum,
+ BaseItemKind.MusicArtist,
+ BaseItemKind.MusicVideo,
+ BaseItemKind.Series,
+ BaseItemKind.Trailer
+ ]
+ },
+ {
+ BaseItemKind.Year, [
+ BaseItemKind.Audio,
+ BaseItemKind.Episode,
+ BaseItemKind.Movie,
+ BaseItemKind.LiveTvProgram,
+ BaseItemKind.MusicAlbum,
+ BaseItemKind.MusicArtist,
+ BaseItemKind.MusicVideo,
+ BaseItemKind.Series,
+ BaseItemKind.Trailer
+ ]
+ }
+ }.ToFrozenDictionary();
+
private readonly ILogger<DtoService> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IUserDataManager _userDataRepository;
@@ -51,7 +122,7 @@ namespace Emby.Server.Implementations.Dto
private readonly Lazy<ILiveTvManager> _livetvManagerFactory;
private readonly ITrickplayManager _trickplayManager;
- private readonly IChapterRepository _chapterRepository;
+ private readonly IChapterManager _chapterManager;
public DtoService(
ILogger<DtoService> logger,
@@ -64,7 +135,7 @@ namespace Emby.Server.Implementations.Dto
IMediaSourceManager mediaSourceManager,
Lazy<ILiveTvManager> livetvManagerFactory,
ITrickplayManager trickplayManager,
- IChapterRepository chapterRepository)
+ IChapterManager chapterManager)
{
_logger = logger;
_libraryManager = libraryManager;
@@ -76,7 +147,7 @@ namespace Emby.Server.Implementations.Dto
_mediaSourceManager = mediaSourceManager;
_livetvManagerFactory = livetvManagerFactory;
_trickplayManager = trickplayManager;
- _chapterRepository = chapterRepository;
+ _chapterManager = chapterManager;
}
private ILiveTvManager LivetvManager => _livetvManagerFactory.Value;
@@ -103,21 +174,9 @@ namespace Emby.Server.Implementations.Dto
(programTuples ??= []).Add((item, dto));
}
- if (item is IItemByName byName)
+ if (options.ContainsField(ItemFields.ItemCounts))
{
- if (options.ContainsField(ItemFields.ItemCounts))
- {
- var libraryItems = byName.GetTaggedItems(new InternalItemsQuery(user)
- {
- Recursive = true,
- DtoOptions = new DtoOptions(false)
- {
- EnableImages = false
- }
- });
-
- SetItemByNameInfo(item, dto, libraryItems);
- }
+ SetItemByNameInfo(dto, user);
}
returnItems[index] = dto;
@@ -148,34 +207,14 @@ namespace Emby.Server.Implementations.Dto
LivetvManager.AddInfoToProgramDto(new[] { (item, dto) }, options.Fields, user).GetAwaiter().GetResult();
}
- if (item is IItemByName itemByName
- && options.ContainsField(ItemFields.ItemCounts))
+ if (options.ContainsField(ItemFields.ItemCounts))
{
- SetItemByNameInfo(
- item,
- dto,
- GetTaggedItems(
- itemByName,
- user,
- new DtoOptions(false)
- {
- EnableImages = false
- }));
+ SetItemByNameInfo(dto, user);
}
return dto;
}
- private static IReadOnlyList<BaseItem> GetTaggedItems(IItemByName byName, User? user, DtoOptions options)
- {
- return byName.GetTaggedItems(
- new InternalItemsQuery(user)
- {
- Recursive = true,
- DtoOptions = options
- });
- }
-
private BaseItemDto GetBaseItemDtoInternal(BaseItem item, DtoOptions options, User? user = null, BaseItem? owner = null)
{
var dto = new BaseItemDto
@@ -316,11 +355,15 @@ namespace Emby.Server.Implementations.Dto
}
/// <inheritdoc />
+ /// TODO refactor this to use the new SetItemByNameInfo.
+ /// Some callers already have the counts extracted so no reason to retrieve them again.
public BaseItemDto GetItemByNameDto(BaseItem item, DtoOptions options, List<BaseItem>? taggedItems, User? user = null)
{
var dto = GetBaseItemDtoInternal(item, options, user);
- if (taggedItems is not null && options.ContainsField(ItemFields.ItemCounts))
+ if (options.ContainsField(ItemFields.ItemCounts)
+ && taggedItems is not null
+ && taggedItems.Count != 0)
{
SetItemByNameInfo(item, dto, taggedItems);
}
@@ -328,6 +371,57 @@ namespace Emby.Server.Implementations.Dto
return dto;
}
+ private void SetItemByNameInfo(BaseItemDto dto, User? user)
+ {
+ if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds))
+ {
+ return;
+ }
+
+ var query = new InternalItemsQuery(user)
+ {
+ Recursive = true,
+ DtoOptions = new DtoOptions(false) { EnableImages = false },
+ IncludeItemTypes = relatedItemKinds
+ };
+
+ switch (dto.Type)
+ {
+ case BaseItemKind.Genre:
+ case BaseItemKind.MusicGenre:
+ query.GenreIds = [dto.Id];
+ break;
+ case BaseItemKind.MusicArtist:
+ query.ArtistIds = [dto.Id];
+ break;
+ case BaseItemKind.Person:
+ query.PersonIds = [dto.Id];
+ break;
+ case BaseItemKind.Studio:
+ query.StudioIds = [dto.Id];
+ break;
+ case BaseItemKind.Year
+ when int.TryParse(dto.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year):
+ query.Years = [year];
+ break;
+ default:
+ return;
+ }
+
+ var counts = _libraryManager.GetItemCounts(query);
+
+ dto.AlbumCount = counts.AlbumCount;
+ dto.ArtistCount = counts.ArtistCount;
+ dto.EpisodeCount = counts.EpisodeCount;
+ dto.MovieCount = counts.MovieCount;
+ dto.MusicVideoCount = counts.MusicVideoCount;
+ dto.ProgramCount = counts.ProgramCount;
+ dto.SeriesCount = counts.SeriesCount;
+ dto.SongCount = counts.SongCount;
+ dto.TrailerCount = counts.TrailerCount;
+ dto.ChildCount = counts.TotalItemCount();
+ }
+
private static void SetItemByNameInfo(BaseItem item, BaseItemDto dto, IReadOnlyList<BaseItem> taggedItems)
{
if (item is MusicArtist)
@@ -957,30 +1051,15 @@ namespace Emby.Server.Implementations.Dto
// Include artists that are not in the database yet, e.g., just added via metadata editor
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
- dto.ArtistItems = hasArtist.Artists
- // .Except(foundArtists, new DistinctNameComparer())
+ dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
+ .Where(e => e.Value.Length > 0)
.Select(i =>
{
- // This should not be necessary but we're seeing some cases of it
- if (string.IsNullOrEmpty(i))
- {
- return null;
- }
-
- var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
+ return new NameGuidPair
{
- EnableImages = false
- });
- if (artist is not null)
- {
- return new NameGuidPair
- {
- Name = artist.Name,
- Id = artist.Id
- };
- }
-
- return null;
+ Name = i.Key,
+ Id = i.Value.First().Id
+ };
}).Where(i => i is not null).ToArray();
}
@@ -1061,12 +1140,17 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.Chapters))
{
- dto.Chapters = _chapterRepository.GetChapters(item.Id).ToList();
+ dto.Chapters = _chapterManager.GetChapters(item.Id).ToList();
}
if (options.ContainsField(ItemFields.Trickplay))
{
- dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
+ var trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
+ dto.Trickplay = trickplay.ToDictionary(
+ mediaStream => mediaStream.Key,
+ mediaStream => mediaStream.Value.ToDictionary(
+ width => width.Key,
+ width => new TrickplayInfoDto(width.Value)));
}
dto.ExtraType = video.ExtraType;
diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
index d99923b4f..15843730e 100644
--- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj
+++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
@@ -65,6 +65,10 @@
</ItemGroup>
<ItemGroup>
+ <PackageReference Include="Ignore" />
+ </ItemGroup>
+
+ <ItemGroup>
<EmbeddedResource Include="Localization\iso6392.txt" />
<EmbeddedResource Include="Localization\countries.json" />
<EmbeddedResource Include="Localization\Core\*.json" />
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index a720c86fb..373b0994a 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
@@ -57,7 +57,7 @@ namespace Emby.Server.Implementations.HttpServer
RemoteEndPoint = remoteEndPoint;
_jsonOptions = JsonDefaults.Options;
- LastActivityDate = DateTime.Now;
+ LastActivityDate = DateTime.UtcNow;
}
/// <inheritdoc />
diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs
index 7378cf885..f63408403 100644
--- a/Emby.Server.Implementations/IO/FileRefresher.cs
+++ b/Emby.Server.Implementations/IO/FileRefresher.cs
@@ -130,7 +130,7 @@ namespace Emby.Server.Implementations.IO
private void ProcessPathChanges(List<string> paths)
{
IEnumerable<BaseItem> itemsToRefresh = paths
- .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Distinct()
.Select(GetAffectedBaseItem)
.Where(item => item is not null)
.DistinctBy(x => x!.Id)!; // Removed null values in the previous .Where()
diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs
index 6af2a553d..d87ad729e 100644
--- a/Emby.Server.Implementations/IO/LibraryMonitor.cs
+++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs
@@ -121,7 +121,7 @@ namespace Emby.Server.Implementations.IO
.Where(IsLibraryMonitorEnabled)
.OfType<Folder>()
.SelectMany(f => f.PhysicalLocations)
- .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Distinct()
.Order();
foreach (var path in paths)
diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
index ac5933a69..fad97344b 100644
--- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs
+++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
@@ -6,6 +6,7 @@ using System.Linq;
using System.Security;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
@@ -152,6 +153,10 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc />
public void MoveDirectory(string source, string destination)
{
+ // Make sure parent directory of target exists
+ var parent = Directory.GetParent(destination);
+ parent?.Create();
+
try
{
Directory.Move(source, destination);
@@ -160,12 +165,13 @@ namespace Emby.Server.Implementations.IO
{
// Cross device move requires a copy
Directory.CreateDirectory(destination);
- foreach (string file in Directory.GetFiles(source))
+ var sourceDir = new DirectoryInfo(source);
+ foreach (var file in sourceDir.EnumerateFiles())
{
- File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), true);
+ file.CopyTo(Path.Combine(destination, file.Name), true);
}
- Directory.Delete(source, true);
+ sourceDir.Delete(true);
}
}
@@ -247,47 +253,40 @@ namespace Emby.Server.Implementations.IO
{
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
- // if (!result.IsDirectory)
- // {
- // result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden;
- // }
-
if (info is FileInfo fileInfo)
{
- result.Length = fileInfo.Length;
-
- // Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes!
- if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
+ result.CreationTimeUtc = GetCreationTimeUtc(info);
+ result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
+ if (fileInfo.LinkTarget is not null)
{
try
{
- using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
+ var targetFileInfo = FileSystemHelper.ResolveLinkTarget(fileInfo, returnFinalTarget: true);
+ if (targetFileInfo is not null)
{
- result.Length = RandomAccess.GetLength(fileHandle);
+ result.Exists = targetFileInfo.Exists;
+ if (result.Exists)
+ {
+ result.Length = targetFileInfo.Length;
+ result.CreationTimeUtc = GetCreationTimeUtc(targetFileInfo);
+ result.LastWriteTimeUtc = GetLastWriteTimeUtc(targetFileInfo);
+ }
+ }
+ else
+ {
+ result.Exists = false;
}
- }
- catch (FileNotFoundException ex)
- {
- // Dangling symlinks cannot be detected before opening the file unfortunately...
- _logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName);
- result.Exists = false;
}
catch (UnauthorizedAccessException ex)
{
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
}
- catch (IOException ex)
- {
- // IOException generally means the file is not accessible due to filesystem issues
- // Catch this exception and mark the file as not exist to ignore it
- _logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName);
- result.Exists = false;
- }
+ }
+ else
+ {
+ result.Length = fileInfo.Length;
}
}
-
- result.CreationTimeUtc = GetCreationTimeUtc(info);
- result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
}
else
{
diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs
index 8b2869149..4874eca8e 100644
--- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs
+++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs
@@ -43,13 +43,11 @@ namespace Emby.Server.Implementations.Images
protected IImageProcessor ImageProcessor { get; set; }
protected virtual IReadOnlyCollection<ImageType> SupportedImages { get; }
- = new ImageType[] { ImageType.Primary };
+ = [ImageType.Primary];
/// <inheritdoc />
public string Name => "Dynamic Image Provider";
- protected virtual int MaxImageAgeDays => 7;
-
public int Order => 0;
protected virtual bool Supports(BaseItem item) => true;
@@ -292,8 +290,14 @@ namespace Emby.Server.Implementations.Images
protected virtual bool HasChangedByDate(BaseItem item, ItemImageInfo image)
{
- var age = DateTime.UtcNow - image.DateModified;
- return age.TotalDays > MaxImageAgeDays;
+ var path = image.Path;
+ if (!string.IsNullOrEmpty(path))
+ {
+ var modificationDate = FileSystem.GetLastWriteTimeUtc(path);
+ return image.DateModified != modificationDate;
+ }
+
+ return false;
}
protected string CreateSingleImage(IEnumerable<BaseItem> itemsWithImages, string outputPathWithoutExtension, ImageType imageType)
diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
index b01fd93a7..ca0744a17 100644
--- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
+++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
@@ -37,46 +37,33 @@ namespace Emby.Server.Implementations.Library
return false;
}
+ if (IgnorePatterns.ShouldIgnore(fileInfo.FullName))
+ {
+ return true;
+ }
+
// Don't ignore top level folders
- if (fileInfo.IsDirectory && parent is AggregateFolder)
+ if (fileInfo.IsDirectory
+ && (parent is AggregateFolder || (parent?.IsTopParent ?? false)))
{
return false;
}
- if (IgnorePatterns.ShouldIgnore(fileInfo.FullName))
+ if (parent is null)
{
- return true;
+ return false;
}
- var filename = fileInfo.Name;
-
if (fileInfo.IsDirectory)
{
- if (parent is not null)
- {
- // Ignore extras folders but allow it at the collection level
- if (_namingOptions.AllExtrasTypesFolderNames.ContainsKey(filename)
- && parent is not AggregateFolder
- && parent is not UserRootFolder)
- {
- return true;
- }
- }
- }
- else
- {
- if (parent is not null)
- {
- // Don't resolve these into audio files
- if (Path.GetFileNameWithoutExtension(filename.AsSpan()).Equals(BaseItem.ThemeSongFileName, StringComparison.Ordinal)
- && AudioFileParser.IsAudioFile(filename, _namingOptions))
- {
- return true;
- }
- }
+ // Ignore extras for unsupported types
+ return _namingOptions.AllExtrasTypesFolderNames.ContainsKey(fileInfo.Name)
+ && parent is not UserRootFolder;
}
- return false;
+ // Don't resolve theme songs
+ return Path.GetFileNameWithoutExtension(fileInfo.Name.AsSpan()).Equals(BaseItem.ThemeSongFileName, StringComparison.Ordinal)
+ && AudioFileParser.IsAudioFile(fileInfo.Name, _namingOptions);
}
}
}
diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
new file mode 100644
index 000000000..473ff8e1d
--- /dev/null
+++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs
@@ -0,0 +1,96 @@
+using System;
+using System.IO;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Resolvers;
+using MediaBrowser.Model.IO;
+
+namespace Emby.Server.Implementations.Library;
+
+/// <summary>
+/// Resolver rule class for ignoring files via .ignore.
+/// </summary>
+public class DotIgnoreIgnoreRule : IResolverIgnoreRule
+{
+ private static readonly bool IsWindows = OperatingSystem.IsWindows();
+
+ private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
+ {
+ for (var current = directory; current is not null; current = current.Parent)
+ {
+ var ignorePath = Path.Join(current.FullName, ".ignore");
+ if (File.Exists(ignorePath))
+ {
+ return new FileInfo(ignorePath);
+ }
+ }
+
+ return null;
+ }
+
+ /// <inheritdoc />
+ public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
+
+ /// <summary>
+ /// Checks whether or not the file is ignored.
+ /// </summary>
+ /// <param name="fileInfo">The file information.</param>
+ /// <param name="parent">The parent BaseItem.</param>
+ /// <returns>True if the file should be ignored.</returns>
+ public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
+ {
+ var searchDirectory = fileInfo.IsDirectory
+ ? new DirectoryInfo(fileInfo.FullName)
+ : new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
+
+ if (string.IsNullOrEmpty(searchDirectory.FullName))
+ {
+ return false;
+ }
+
+ var ignoreFile = FindIgnoreFile(searchDirectory);
+ if (ignoreFile is null)
+ {
+ return false;
+ }
+
+ // Fast path in case the ignore files isn't a symlink and is empty
+ if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
+ {
+ // Ignore directory if we just have the file
+ return true;
+ }
+
+ var content = GetFileContent(ignoreFile);
+ return string.IsNullOrWhiteSpace(content)
+ || CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
+ }
+
+ private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
+ {
+ // If file has content, base ignoring off the content .gitignore-style rules
+ var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var ignore = new Ignore.Ignore();
+ ignore.Add(rules);
+
+ // Mitigate the problem of the Ignore library not handling Windows paths correctly.
+ // See https://github.com/jellyfin/jellyfin/issues/15484
+ var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
+
+ // Add trailing slash for directories to match "folder/"
+ if (isDirectory)
+ {
+ pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
+ }
+
+ return ignore.IsIgnored(pathToCheck);
+ }
+
+ private static string GetFileContent(FileInfo ignoreFile)
+ {
+ ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
+ return ignoreFile.Exists
+ ? File.ReadAllText(ignoreFile.FullName)
+ : string.Empty;
+ }
+}
diff --git a/Emby.Server.Implementations/Library/ExternalDataManager.cs b/Emby.Server.Implementations/Library/ExternalDataManager.cs
new file mode 100644
index 000000000..4ad0f999b
--- /dev/null
+++ b/Emby.Server.Implementations/Library/ExternalDataManager.cs
@@ -0,0 +1,77 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Chapters;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Controller.Trickplay;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.Library;
+
+/// <summary>
+/// IExternalDataManager implementation.
+/// </summary>
+public class ExternalDataManager : IExternalDataManager
+{
+ private readonly IKeyframeManager _keyframeManager;
+ private readonly IMediaSegmentManager _mediaSegmentManager;
+ private readonly IPathManager _pathManager;
+ private readonly ITrickplayManager _trickplayManager;
+ private readonly IChapterManager _chapterManager;
+ private readonly ILogger<ExternalDataManager> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ExternalDataManager"/> class.
+ /// </summary>
+ /// <param name="keyframeManager">The keyframe manager.</param>
+ /// <param name="mediaSegmentManager">The media segment manager.</param>
+ /// <param name="pathManager">The path manager.</param>
+ /// <param name="trickplayManager">The trickplay manager.</param>
+ /// <param name="chapterManager">The chapter manager.</param>
+ /// <param name="logger">The logger.</param>
+ public ExternalDataManager(
+ IKeyframeManager keyframeManager,
+ IMediaSegmentManager mediaSegmentManager,
+ IPathManager pathManager,
+ ITrickplayManager trickplayManager,
+ IChapterManager chapterManager,
+ ILogger<ExternalDataManager> logger)
+ {
+ _keyframeManager = keyframeManager;
+ _mediaSegmentManager = mediaSegmentManager;
+ _pathManager = pathManager;
+ _trickplayManager = trickplayManager;
+ _chapterManager = chapterManager;
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
+ {
+ var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
+ var itemId = item.Id;
+ if (validPaths.Count > 0)
+ {
+ foreach (var path in validPaths)
+ {
+ try
+ {
+ Directory.Delete(path, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
+ }
+ }
+ }
+
+ await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
+ await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
+ await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
+ await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs
index bb45dd87e..fe3a1ce61 100644
--- a/Emby.Server.Implementations/Library/IgnorePatterns.cs
+++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs
@@ -1,5 +1,4 @@
using System;
-using System.Linq;
using DotNet.Globbing;
namespace Emby.Server.Implementations.Library
@@ -49,6 +48,8 @@ namespace Emby.Server.Implementations.Library
"**/.wd_tv",
"**/lost+found/**",
"**/lost+found",
+ "**/subs/**",
+ "**/subs",
// Trickplay files
"**/*.trickplay",
diff --git a/Emby.Server.Implementations/Library/KeyframeManager.cs b/Emby.Server.Implementations/Library/KeyframeManager.cs
new file mode 100644
index 000000000..18f4ce047
--- /dev/null
+++ b/Emby.Server.Implementations/Library/KeyframeManager.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.MediaEncoding.Keyframes;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.Persistence;
+
+namespace Emby.Server.Implementations.Library;
+
+/// <summary>
+/// Manager for Keyframe data.
+/// </summary>
+public class KeyframeManager : IKeyframeManager
+{
+ private readonly IKeyframeRepository _repository;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="KeyframeManager"/> class.
+ /// </summary>
+ /// <param name="repository">The keyframe repository.</param>
+ public KeyframeManager(IKeyframeRepository repository)
+ {
+ _repository = repository;
+ }
+
+ /// <inheritdoc />
+ public IReadOnlyList<KeyframeData> GetKeyframeData(Guid itemId)
+ {
+ return _repository.GetKeyframeData(itemId);
+ }
+
+ /// <inheritdoc />
+ public async Task SaveKeyframeDataAsync(Guid itemId, KeyframeData data, CancellationToken cancellationToken)
+ {
+ await _repository.SaveKeyframeDataAsync(itemId, data, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// <inheritdoc />
+ public async Task DeleteKeyframeDataAsync(Guid itemId, CancellationToken cancellationToken)
+ {
+ await _repository.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 1303bb3cb..cab87e53d 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -34,10 +34,12 @@ using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Controller.Sorting;
+using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Drawing;
@@ -66,11 +68,11 @@ namespace Emby.Server.Implementations.Library
private readonly ILogger<LibraryManager> _logger;
private readonly ITaskManager _taskManager;
private readonly IUserManager _userManager;
- private readonly IUserDataManager _userDataRepository;
+ private readonly IUserDataManager _userDataManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly Lazy<ILibraryMonitor> _libraryMonitorFactory;
private readonly Lazy<IProviderManager> _providerManagerFactory;
- private readonly Lazy<IUserViewManager> _userviewManagerFactory;
+ private readonly Lazy<IUserViewManager> _userViewManagerFactory;
private readonly IServerApplicationHost _appHost;
private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem;
@@ -106,11 +108,11 @@ namespace Emby.Server.Implementations.Library
/// <param name="taskManager">The task manager.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="configurationManager">The configuration manager.</param>
- /// <param name="userDataRepository">The user data repository.</param>
+ /// <param name="userDataManager">The user data manager.</param>
/// <param name="libraryMonitorFactory">The library monitor.</param>
/// <param name="fileSystem">The file system.</param>
/// <param name="providerManagerFactory">The provider manager.</param>
- /// <param name="userviewManagerFactory">The userview 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="imageProcessor">The image processor.</param>
@@ -124,11 +126,11 @@ namespace Emby.Server.Implementations.Library
ITaskManager taskManager,
IUserManager userManager,
IServerConfigurationManager configurationManager,
- IUserDataManager userDataRepository,
+ IUserDataManager userDataManager,
Lazy<ILibraryMonitor> libraryMonitorFactory,
IFileSystem fileSystem,
Lazy<IProviderManager> providerManagerFactory,
- Lazy<IUserViewManager> userviewManagerFactory,
+ Lazy<IUserViewManager> userViewManagerFactory,
IMediaEncoder mediaEncoder,
IItemRepository itemRepository,
IImageProcessor imageProcessor,
@@ -142,11 +144,11 @@ namespace Emby.Server.Implementations.Library
_taskManager = taskManager;
_userManager = userManager;
_configurationManager = configurationManager;
- _userDataRepository = userDataRepository;
+ _userDataManager = userDataManager;
_libraryMonitorFactory = libraryMonitorFactory;
_fileSystem = fileSystem;
_providerManagerFactory = providerManagerFactory;
- _userviewManagerFactory = userviewManagerFactory;
+ _userViewManagerFactory = userViewManagerFactory;
_mediaEncoder = mediaEncoder;
_itemRepository = itemRepository;
_imageProcessor = imageProcessor;
@@ -202,13 +204,13 @@ namespace Emby.Server.Implementations.Library
private IProviderManager ProviderManager => _providerManagerFactory.Value;
- private IUserViewManager UserViewManager => _userviewManagerFactory.Value;
+ private IUserViewManager UserViewManager => _userViewManagerFactory.Value;
/// <summary>
/// Gets or sets the postscan tasks.
/// </summary>
/// <value>The postscan tasks.</value>
- private ILibraryPostScanTask[] PostscanTasks { get; set; } = [];
+ private ILibraryPostScanTask[] PostScanTasks { get; set; } = [];
/// <summary>
/// Gets or sets the intro providers.
@@ -245,20 +247,20 @@ namespace Emby.Server.Implementations.Library
/// <param name="resolvers">The resolvers.</param>
/// <param name="introProviders">The intro providers.</param>
/// <param name="itemComparers">The item comparers.</param>
- /// <param name="postscanTasks">The post scan tasks.</param>
+ /// <param name="postScanTasks">The post scan tasks.</param>
public void AddParts(
IEnumerable<IResolverIgnoreRule> rules,
IEnumerable<IItemResolver> resolvers,
IEnumerable<IIntroProvider> introProviders,
IEnumerable<IBaseItemComparer> itemComparers,
- IEnumerable<ILibraryPostScanTask> postscanTasks)
+ IEnumerable<ILibraryPostScanTask> postScanTasks)
{
EntityResolutionIgnoreRules = rules.ToArray();
EntityResolvers = resolvers.OrderBy(i => i.Priority).ToArray();
MultiItemResolvers = EntityResolvers.OfType<IMultiItemResolver>().ToArray();
IntroProviders = introProviders.ToArray();
Comparers = itemComparers.ToArray();
- PostscanTasks = postscanTasks.ToArray();
+ PostScanTasks = postScanTasks.ToArray();
}
/// <summary>
@@ -325,6 +327,45 @@ namespace Emby.Server.Implementations.Library
DeleteItem(item, options, parent, notifyParentItem);
}
+ public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items)
+ {
+ var pathMaps = items.Select(e => (Item: e, InternalPath: GetInternalMetadataPaths(e), DeletePaths: e.GetDeletePaths())).ToArray();
+
+ foreach (var (item, internalPaths, pathsToDelete) in pathMaps)
+ {
+ foreach (var metadataPath in internalPaths)
+ {
+ if (!Directory.Exists(metadataPath))
+ {
+ continue;
+ }
+
+ _logger.LogDebug(
+ "Deleting metadata path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
+ item.GetType().Name,
+ item.Name ?? "Unknown name",
+ metadataPath,
+ item.Id);
+
+ try
+ {
+ Directory.Delete(metadataPath, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting {MetadataPath}", metadataPath);
+ }
+ }
+
+ foreach (var fileSystemInfo in pathsToDelete)
+ {
+ DeleteItemPath(item, false, fileSystemInfo);
+ }
+ }
+
+ _itemRepository.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
+ }
+
public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem)
{
ArgumentNullException.ThrowIfNull(item);
@@ -393,7 +434,7 @@ namespace Emby.Server.Implementations.Library
}
}
- if (options.DeleteFileLocation && item.IsFileProtocol)
+ if ((options.DeleteFileLocation && item.IsFileProtocol) || IsInternalItem(item))
{
// Assume only the first is required
// Add this flag to GetDeletePaths if required in the future
@@ -401,59 +442,7 @@ namespace Emby.Server.Implementations.Library
foreach (var fileSystemInfo in item.GetDeletePaths())
{
- if (Directory.Exists(fileSystemInfo.FullName) || File.Exists(fileSystemInfo.FullName))
- {
- try
- {
- _logger.LogInformation(
- "Deleting item path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
- item.GetType().Name,
- item.Name ?? "Unknown name",
- fileSystemInfo.FullName,
- item.Id);
-
- if (fileSystemInfo.IsDirectory)
- {
- Directory.Delete(fileSystemInfo.FullName, true);
- }
- else
- {
- File.Delete(fileSystemInfo.FullName);
- }
- }
- catch (DirectoryNotFoundException)
- {
- _logger.LogInformation(
- "Directory not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
- item.GetType().Name,
- item.Name ?? "Unknown name",
- fileSystemInfo.FullName,
- item.Id);
- }
- catch (FileNotFoundException)
- {
- _logger.LogInformation(
- "File not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
- item.GetType().Name,
- item.Name ?? "Unknown name",
- fileSystemInfo.FullName,
- item.Id);
- }
- catch (IOException)
- {
- if (isRequiredForDelete)
- {
- throw;
- }
- }
- catch (UnauthorizedAccessException)
- {
- if (isRequiredForDelete)
- {
- throw;
- }
- }
- }
+ DeleteItemPath(item, isRequiredForDelete, fileSystemInfo);
isRequiredForDelete = false;
}
@@ -461,17 +450,109 @@ namespace Emby.Server.Implementations.Library
item.SetParent(null);
- _itemRepository.DeleteItem(item.Id);
+ _itemRepository.DeleteItem([item.Id, .. children.Select(f => f.Id)]);
_cache.TryRemove(item.Id, out _);
foreach (var child in children)
{
- _itemRepository.DeleteItem(child.Id);
_cache.TryRemove(child.Id, out _);
}
+ if (parent is Folder folder)
+ {
+ folder.Children = null;
+ folder.UserData = null;
+ }
+
ReportItemRemoved(item, parent);
}
+ private void DeleteItemPath(BaseItem item, bool isRequiredForDelete, FileSystemMetadata fileSystemInfo)
+ {
+ if (Directory.Exists(fileSystemInfo.FullName) || File.Exists(fileSystemInfo.FullName))
+ {
+ try
+ {
+ _logger.LogInformation(
+ "Deleting item path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
+ item.GetType().Name,
+ item.Name ?? "Unknown name",
+ fileSystemInfo.FullName,
+ item.Id);
+
+ if (fileSystemInfo.IsDirectory)
+ {
+ Directory.Delete(fileSystemInfo.FullName, true);
+ }
+ else
+ {
+ File.Delete(fileSystemInfo.FullName);
+ }
+ }
+ catch (DirectoryNotFoundException)
+ {
+ _logger.LogInformation(
+ "Directory not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
+ item.GetType().Name,
+ item.Name ?? "Unknown name",
+ fileSystemInfo.FullName,
+ item.Id);
+ }
+ catch (FileNotFoundException)
+ {
+ _logger.LogInformation(
+ "File not found, only removing from database, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
+ item.GetType().Name,
+ item.Name ?? "Unknown name",
+ fileSystemInfo.FullName,
+ item.Id);
+ }
+ catch (IOException)
+ {
+ if (isRequiredForDelete)
+ {
+ throw;
+ }
+ }
+ catch (UnauthorizedAccessException)
+ {
+ if (isRequiredForDelete)
+ {
+ throw;
+ }
+ }
+ }
+ }
+
+ private bool IsInternalItem(BaseItem item)
+ {
+ if (!item.IsFileProtocol)
+ {
+ return false;
+ }
+
+ var pathToCheck = item switch
+ {
+ Genre => _configurationManager.ApplicationPaths.GenrePath,
+ MusicArtist => _configurationManager.ApplicationPaths.ArtistsPath,
+ MusicGenre => _configurationManager.ApplicationPaths.MusicGenrePath,
+ Person => _configurationManager.ApplicationPaths.PeoplePath,
+ Studio => _configurationManager.ApplicationPaths.StudioPath,
+ Year => _configurationManager.ApplicationPaths.YearPath,
+ _ => null
+ };
+
+ var itemPath = item.Path;
+ if (!string.IsNullOrEmpty(pathToCheck) && !string.IsNullOrEmpty(itemPath))
+ {
+ var cleanPath = _fileSystem.GetValidFilename(itemPath);
+ var cleanCheckPath = _fileSystem.GetValidFilename(pathToCheck);
+
+ return cleanPath.StartsWith(cleanCheckPath, StringComparison.Ordinal);
+ }
+
+ return false;
+ }
+
private List<string> GetMetadataPaths(BaseItem item, IEnumerable<BaseItem> children)
{
var list = GetInternalMetadataPaths(item);
@@ -639,7 +720,7 @@ namespace Emby.Server.Implementations.Library
}
}
- // Need to remove subpaths that may have been resolved from shortcuts
+ // Need to remove sub-paths that may have been resolved from shortcuts
// Example: if \\server\movies exists, then strip out \\server\movies\action
if (isPhysicalRoot)
{
@@ -649,10 +730,11 @@ namespace Emby.Server.Implementations.Library
args.FileSystemChildren = files;
}
- // Check to see if we should resolve based on our contents
- if (args.IsDirectory && !ShouldResolvePathContents(args))
+ // Filter content based on ignore rules
+ if (args.IsDirectory)
{
- return null;
+ var filtered = args.GetActualFileSystemChildren().ToArray();
+ args.FileSystemChildren = filtered ?? [];
}
return ResolveItem(args, resolvers);
@@ -667,10 +749,10 @@ namespace Emby.Server.Implementations.Library
var list = originalList.Where(i => i.IsDirectory)
.Select(i => Path.TrimEndingDirectorySeparator(i.FullName))
- .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Distinct()
.ToList();
- var dupes = list.Where(subPath => !subPath.EndsWith(":\\", StringComparison.OrdinalIgnoreCase) && list.Any(i => _fileSystem.ContainsSubPath(i, subPath)))
+ var dupes = list.Where(subPath => !subPath.EndsWith(":\\", StringComparison.Ordinal) && list.Any(i => _fileSystem.ContainsSubPath(i, subPath)))
.ToList();
foreach (var dupe in dupes)
@@ -678,22 +760,11 @@ namespace Emby.Server.Implementations.Library
_logger.LogInformation("Found duplicate path: {0}", dupe);
}
- var newList = list.Except(dupes, StringComparer.OrdinalIgnoreCase).Select(_fileSystem.GetDirectoryInfo).ToList();
+ var newList = list.Except(dupes, StringComparer.Ordinal).Select(_fileSystem.GetDirectoryInfo).ToList();
newList.AddRange(originalList.Where(i => !i.IsDirectory));
return newList;
}
- /// <summary>
- /// Determines whether a path should be ignored based on its contents - called after the contents have been read.
- /// </summary>
- /// <param name="args">The args.</param>
- /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns>
- private static bool ShouldResolvePathContents(ItemResolveArgs args)
- {
- // Ignore any folders containing a file called .ignore
- return !args.ContainsFileSystemEntryByName(".ignore");
- }
-
public IEnumerable<BaseItem> ResolvePaths(IEnumerable<FileSystemMetadata> files, IDirectoryService directoryService, Folder parent, LibraryOptions libraryOptions, CollectionType? collectionType = null)
{
return ResolvePaths(files, directoryService, parent, libraryOptions, collectionType, EntityResolvers);
@@ -768,8 +839,6 @@ namespace Emby.Server.Implementations.Library
{
var rootFolderPath = _configurationManager.ApplicationPaths.RootFolderPath;
- Directory.CreateDirectory(rootFolderPath);
-
var rootFolder = GetItemById(GetNewItemId(rootFolderPath, typeof(AggregateFolder))) as AggregateFolder ??
(ResolvePath(_fileSystem.GetDirectoryInfo(rootFolderPath)) as Folder ?? throw new InvalidOperationException("Something went very wong"))
.DeepCopy<Folder, AggregateFolder>();
@@ -784,11 +853,12 @@ namespace Emby.Server.Implementations.Library
// Add in the plug-in folders
var path = Path.Combine(_configurationManager.ApplicationPaths.DataPath, "playlists");
- Directory.CreateDirectory(path);
-
+ var info = Directory.CreateDirectory(path);
Folder folder = new PlaylistsFolder
{
- Path = path
+ Path = path,
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc,
};
if (folder.Id.IsEmpty())
@@ -805,6 +875,7 @@ namespace Emby.Server.Implementations.Library
if (!folder.ParentId.Equals(rootFolder.Id))
{
+ rootFolder.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, CancellationToken.None).GetAwaiter().GetResult();
folder.ParentId = rootFolder.Id;
folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, CancellationToken.None).GetAwaiter().GetResult();
}
@@ -874,7 +945,7 @@ namespace Emby.Server.Implementations.Library
{
Path = path,
IsFolder = isFolder,
- OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) },
+ OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending)],
Limit = 1,
DtoOptions = new DtoOptions(true)
};
@@ -968,6 +1039,11 @@ namespace Emby.Server.Implementations.Library
return GetArtist(name, new DtoOptions(true));
}
+ public IReadOnlyDictionary<string, MusicArtist[]> GetArtists(IReadOnlyList<string> names)
+ {
+ return _itemRepository.FindArtists(names);
+ }
+
public MusicArtist GetArtist(string name, DtoOptions options)
{
return CreateItemByName<MusicArtist>(MusicArtist.GetPath, name, options);
@@ -980,7 +1056,7 @@ namespace Emby.Server.Implementations.Library
{
var existing = GetItemList(new InternalItemsQuery
{
- IncludeItemTypes = new[] { BaseItemKind.MusicArtist },
+ IncludeItemTypes = [BaseItemKind.MusicArtist],
Name = name,
DtoOptions = options
}).Cast<MusicArtist>()
@@ -999,12 +1075,13 @@ namespace Emby.Server.Implementations.Library
var item = GetItemById(id) as T;
if (item is null)
{
+ var info = Directory.CreateDirectory(path);
item = new T
{
Name = name,
Id = id,
- DateCreated = DateTime.UtcNow,
- DateModified = DateTime.UtcNow,
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc,
Path = path
};
@@ -1068,6 +1145,7 @@ namespace Emby.Server.Implementations.Library
public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
{
+ RootFolder.Children = null;
await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
// Start by just validating the children of the root, but go no further
@@ -1078,9 +1156,12 @@ namespace Emby.Server.Implementations.Library
allowRemoveRoot: removeRoot,
cancellationToken: cancellationToken).ConfigureAwait(false);
- await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ var rootFolder = GetUserRootFolder();
+ rootFolder.Children = null;
+
+ await rootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
- await GetUserRootFolder().ValidateChildren(
+ await rootFolder.ValidateChildren(
new Progress<double>(),
new MetadataRefreshOptions(new DirectoryService(_fileSystem)),
recursive: false,
@@ -1088,18 +1169,24 @@ namespace Emby.Server.Implementations.Library
cancellationToken: cancellationToken).ConfigureAwait(false);
// Quickly scan CollectionFolders for changes
- foreach (var child in GetUserRootFolder().Children.OfType<Folder>())
+ var toDelete = new List<Guid>();
+ foreach (var child in rootFolder.Children!.OfType<Folder>())
{
// If the user has somehow deleted the collection directory, remove the metadata from the database.
if (child is CollectionFolder collectionFolder && !Directory.Exists(collectionFolder.Path))
{
- _itemRepository.DeleteItem(collectionFolder.Id);
+ toDelete.Add(collectionFolder.Id);
}
else
{
await child.RefreshMetadata(cancellationToken).ConfigureAwait(false);
}
}
+
+ if (toDelete.Count > 0)
+ {
+ _itemRepository.DeleteItem(toDelete.ToArray());
+ }
}
private async Task PerformLibraryValidation(IProgress<double> progress, CancellationToken cancellationToken)
@@ -1130,7 +1217,7 @@ namespace Emby.Server.Implementations.Library
/// <returns>Task.</returns>
private async Task RunPostScanTasks(IProgress<double> progress, CancellationToken cancellationToken)
{
- var tasks = PostscanTasks.ToList();
+ var tasks = PostScanTasks.ToList();
var numComplete = 0;
var numTasks = tasks.Count;
@@ -1253,7 +1340,7 @@ namespace Emby.Server.Implementations.Library
private CollectionTypeOptions? GetCollectionType(string path)
{
- var files = _fileSystem.GetFilePaths(path, new[] { ".collection" }, true, false);
+ var files = _fileSystem.GetFilePaths(path, [".collection"], true, false);
foreach (ReadOnlySpan<char> file in files)
{
if (Enum.TryParse<CollectionTypeOptions>(Path.GetFileNameWithoutExtension(file), true, out var res))
@@ -1324,7 +1411,7 @@ namespace Emby.Server.Implementations.Library
var parent = GetItemById(query.ParentId);
if (parent is not null)
{
- SetTopParentIdsOrAncestors(query, new[] { parent });
+ SetTopParentIdsOrAncestors(query, [parent]);
}
}
@@ -1355,7 +1442,7 @@ namespace Emby.Server.Implementations.Library
var parent = GetItemById(query.ParentId);
if (parent is not null)
{
- SetTopParentIdsOrAncestors(query, new[] { parent });
+ SetTopParentIdsOrAncestors(query, [parent]);
}
}
@@ -1367,6 +1454,25 @@ namespace Emby.Server.Implementations.Library
return _itemRepository.GetCount(query);
}
+ public ItemCounts GetItemCounts(InternalItemsQuery query)
+ {
+ if (query.Recursive && !query.ParentId.IsEmpty())
+ {
+ var parent = GetItemById(query.ParentId);
+ if (parent is not null)
+ {
+ SetTopParentIdsOrAncestors(query, [parent]);
+ }
+ }
+
+ if (query.User is not null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+
+ return _itemRepository.GetItemCounts(query);
+ }
+
public IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents)
{
SetTopParentIdsOrAncestors(query, parents);
@@ -1543,7 +1649,7 @@ namespace Emby.Server.Implementations.Library
var parent = GetItemById(query.ParentId);
if (parent is not null)
{
- SetTopParentIdsOrAncestors(query, new[] { parent });
+ SetTopParentIdsOrAncestors(query, [parent]);
}
}
@@ -1573,7 +1679,7 @@ namespace Emby.Server.Implementations.Library
// Prevent searching in all libraries due to empty filter
if (query.TopParentIds.Length == 0)
{
- query.TopParentIds = new[] { Guid.NewGuid() };
+ query.TopParentIds = [Guid.NewGuid()];
}
}
else
@@ -1584,7 +1690,7 @@ namespace Emby.Server.Implementations.Library
// Prevent searching in all libraries due to empty filter
if (query.AncestorIds.Length == 0)
{
- query.AncestorIds = new[] { Guid.NewGuid() };
+ query.AncestorIds = [Guid.NewGuid()];
}
}
@@ -1613,7 +1719,7 @@ namespace Emby.Server.Implementations.Library
// Prevent searching in all libraries due to empty filter
if (query.TopParentIds.Length == 0)
{
- query.TopParentIds = new[] { Guid.NewGuid() };
+ query.TopParentIds = [Guid.NewGuid()];
}
}
}
@@ -1624,7 +1730,7 @@ namespace Emby.Server.Implementations.Library
{
if (view.ViewType == CollectionType.livetv)
{
- return new[] { view.Id };
+ return [view.Id];
}
// Translate view into folders
@@ -1673,7 +1779,7 @@ namespace Emby.Server.Implementations.Library
var topParent = item.GetTopParent();
if (topParent is not null)
{
- return new[] { topParent.Id };
+ return [topParent.Id];
}
return [];
@@ -1869,7 +1975,7 @@ namespace Emby.Server.Implementations.Library
userComparer.User = user;
userComparer.UserManager = _userManager;
- userComparer.UserDataRepository = _userDataRepository;
+ userComparer.UserDataManager = _userDataManager;
return userComparer;
}
@@ -1880,7 +1986,7 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />
public void CreateItem(BaseItem item, BaseItem? parent)
{
- CreateItems(new[] { item }, parent, CancellationToken.None);
+ CreateItems([item], parent, CancellationToken.None);
}
/// <inheritdoc />
@@ -1893,6 +1999,12 @@ namespace Emby.Server.Implementations.Library
RegisterItem(item);
}
+ if (parent is Folder folder)
+ {
+ folder.Children = null;
+ folder.UserData = null;
+ }
+
if (ItemAdded is not null)
{
foreach (var item in items)
@@ -1932,7 +2044,7 @@ namespace Emby.Server.Implementations.Library
try
{
- return _fileSystem.GetLastWriteTimeUtc(image.Path) != image.DateModified;
+ return image.DateModified.Subtract(_fileSystem.GetLastWriteTimeUtc(image.Path)).Duration().TotalSeconds > 1;
}
catch (Exception ex)
{
@@ -1986,6 +2098,12 @@ namespace Emby.Server.Implementations.Library
}
}
+ if (!File.Exists(image.Path))
+ {
+ _logger.LogWarning("Image not found at {ImagePath}", image.Path);
+ continue;
+ }
+
ImageDimensions size;
try
{
@@ -2003,7 +2121,8 @@ namespace Emby.Server.Implementations.Library
try
{
- image.BlurHash = _imageProcessor.GetImageBlurHash(image.Path, size);
+ var blurhash = _imageProcessor.GetImageBlurHash(image.Path, size);
+ image.BlurHash = blurhash;
}
catch (Exception ex)
{
@@ -2013,7 +2132,8 @@ namespace Emby.Server.Implementations.Library
try
{
- image.DateModified = _fileSystem.GetLastWriteTimeUtc(image.Path);
+ var modifiedDate = _fileSystem.GetLastWriteTimeUtc(image.Path);
+ image.DateModified = modifiedDate;
}
catch (Exception ex)
{
@@ -2021,18 +2141,31 @@ namespace Emby.Server.Implementations.Library
}
}
+ item.ValidateImages();
+
_itemRepository.SaveImages(item);
+
RegisterItem(item);
}
/// <inheritdoc />
public async Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
{
- _itemRepository.SaveItems(items, cancellationToken);
-
foreach (var item in items)
{
+ item.DateLastSaved = DateTime.UtcNow;
await RunMetadataSavers(item, updateReason).ConfigureAwait(false);
+
+ // Modify again, so saved value is after write time of externally saved metadata
+ item.DateLastSaved = DateTime.UtcNow;
+ }
+
+ _itemRepository.SaveItems(items, cancellationToken);
+
+ if (parent is Folder folder)
+ {
+ folder.Children = null;
+ folder.UserData = null;
}
if (ItemUpdated is not null)
@@ -2066,7 +2199,7 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
- => UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken);
+ => UpdateItemsAsync([item], parent, updateReason, cancellationToken);
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
{
@@ -2075,8 +2208,6 @@ namespace Emby.Server.Implementations.Library
await ProviderManager.SaveMetadataAsync(item, updateReason).ConfigureAwait(false);
}
- item.DateLastSaved = DateTime.UtcNow;
-
await UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
}
@@ -2295,13 +2426,13 @@ namespace Emby.Server.Implementations.Library
if (item is null || !string.Equals(item.Path, path, StringComparison.OrdinalIgnoreCase))
{
- Directory.CreateDirectory(path);
-
+ var info = Directory.CreateDirectory(path);
item = new UserView
{
Path = path,
Id = id,
- DateCreated = DateTime.UtcNow,
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc,
Name = name,
ViewType = viewType,
ForcedSortName = sortName
@@ -2343,13 +2474,13 @@ namespace Emby.Server.Implementations.Library
if (item is null)
{
- Directory.CreateDirectory(path);
-
+ var info = Directory.CreateDirectory(path);
item = new UserView
{
Path = path,
Id = id,
- DateCreated = DateTime.UtcNow,
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc,
Name = name,
ViewType = viewType,
ForcedSortName = sortName,
@@ -2362,12 +2493,13 @@ namespace Emby.Server.Implementations.Library
isNew = true;
}
- var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
+ var lastRefreshedUtc = item.DateLastRefreshed;
+ var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
if (!refresh && !item.DisplayParentId.IsEmpty())
{
var displayParent = GetItemById(item.DisplayParentId);
- refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed;
+ refresh = displayParent is not null && displayParent.DateLastSaved > lastRefreshedUtc;
}
if (refresh)
@@ -2407,31 +2539,31 @@ namespace Emby.Server.Implementations.Library
if (item is null)
{
- Directory.CreateDirectory(path);
-
+ var info = Directory.CreateDirectory(path);
item = new UserView
{
Path = path,
Id = id,
- DateCreated = DateTime.UtcNow,
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc,
Name = name,
ViewType = viewType,
- ForcedSortName = sortName
+ ForcedSortName = sortName,
+ DisplayParentId = parentId
};
- item.DisplayParentId = parentId;
-
CreateItem(item, null);
isNew = true;
}
- var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
+ var lastRefreshedUtc = item.DateLastRefreshed;
+ var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
if (!refresh && !item.DisplayParentId.IsEmpty())
{
var displayParent = GetItemById(item.DisplayParentId);
- refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed;
+ refresh = displayParent is not null && displayParent.DateLastSaved > lastRefreshedUtc;
}
if (refresh)
@@ -2477,20 +2609,19 @@ namespace Emby.Server.Implementations.Library
if (item is null)
{
- Directory.CreateDirectory(path);
-
+ var info = Directory.CreateDirectory(path);
item = new UserView
{
Path = path,
Id = id,
- DateCreated = DateTime.UtcNow,
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc,
Name = name,
ViewType = viewType,
- ForcedSortName = sortName
+ ForcedSortName = sortName,
+ DisplayParentId = parentId
};
- item.DisplayParentId = parentId;
-
CreateItem(item, null);
isNew = true;
@@ -2502,12 +2633,13 @@ namespace Emby.Server.Implementations.Library
item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
}
- var refresh = isNew || DateTime.UtcNow - item.DateLastRefreshed >= _viewRefreshInterval;
+ var lastRefreshedUtc = item.DateLastRefreshed;
+ var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval;
if (!refresh && !item.DisplayParentId.IsEmpty())
{
var displayParent = GetItemById(item.DisplayParentId);
- refresh = displayParent is not null && displayParent.DateLastSaved > item.DateLastRefreshed;
+ refresh = displayParent is not null && displayParent.DateLastSaved > lastRefreshedUtc;
}
if (refresh)
@@ -2568,7 +2700,6 @@ namespace Emby.Server.Implementations.Library
var isFolder = episode.VideoType == VideoType.BluRay || episode.VideoType == VideoType.Dvd;
- // TODO nullable - what are we trying to do there with empty episodeInfo?
EpisodeInfo? episodeInfo = null;
if (episode.IsFileProtocol)
{
@@ -2586,44 +2717,12 @@ namespace Emby.Server.Implementations.Library
}
}
- episodeInfo ??= new EpisodeInfo(episode.Path);
-
- try
- {
- var libraryOptions = GetLibraryOptions(episode);
- if (libraryOptions.EnableEmbeddedEpisodeInfos && string.Equals(episodeInfo.Container, "mp4", StringComparison.OrdinalIgnoreCase))
- {
- // Read from metadata
- var mediaInfo = _mediaEncoder.GetMediaInfo(
- new MediaInfoRequest
- {
- MediaSource = episode.GetMediaSources(false)[0],
- MediaType = DlnaProfileType.Video
- },
- CancellationToken.None).GetAwaiter().GetResult();
- if (mediaInfo.ParentIndexNumber > 0)
- {
- episodeInfo.SeasonNumber = mediaInfo.ParentIndexNumber;
- }
-
- if (mediaInfo.IndexNumber > 0)
- {
- episodeInfo.EpisodeNumber = mediaInfo.IndexNumber;
- }
-
- if (!string.IsNullOrEmpty(mediaInfo.ShowName))
- {
- episodeInfo.SeriesName = mediaInfo.ShowName;
- }
- }
- }
- catch (Exception ex)
+ var changed = false;
+ if (episodeInfo is null)
{
- _logger.LogError(ex, "Error reading the episode information with ffprobe. Episode: {EpisodeInfo}", episodeInfo.Path);
+ return changed;
}
- var changed = false;
-
if (episodeInfo.IsByDate)
{
if (episode.IndexNumber.HasValue)
@@ -2726,27 +2825,33 @@ namespace Emby.Server.Implementations.Library
public IEnumerable<BaseItem> FindExtras(BaseItem owner, IReadOnlyList<FileSystemMetadata> fileSystemChildren, IDirectoryService directoryService)
{
+ // Apply .ignore rules
+ var filtered = fileSystemChildren.Where(c => !DotIgnoreIgnoreRule.IsIgnored(c, owner)).ToList();
var ownerVideoInfo = VideoResolver.Resolve(owner.Path, owner.IsFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath);
if (ownerVideoInfo is null)
{
yield break;
}
- var count = fileSystemChildren.Count;
+ var count = filtered.Count;
for (var i = 0; i < count; i++)
{
- var current = fileSystemChildren[i];
+ var current = filtered[i];
if (current.IsDirectory && _namingOptions.AllExtrasTypesFolderNames.ContainsKey(current.Name))
{
var filesInSubFolder = _fileSystem.GetFiles(current.FullName, null, false, false);
- foreach (var file in filesInSubFolder)
+ var filesInSubFolderList = filesInSubFolder.ToList();
+
+ bool subFolderIsMixedFolder = filesInSubFolderList.Count > 1;
+
+ foreach (var file in filesInSubFolderList)
{
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType))
{
continue;
}
- var extra = GetExtra(file, extraType.Value);
+ var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder);
if (extra is not null)
{
yield return extra;
@@ -2755,7 +2860,7 @@ namespace Emby.Server.Implementations.Library
}
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType))
{
- var extra = GetExtra(current, extraType.Value);
+ var extra = GetExtra(current, extraType.Value, false);
if (extra is not null)
{
yield return extra;
@@ -2763,7 +2868,7 @@ namespace Emby.Server.Implementations.Library
}
}
- BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType)
+ BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder)
{
var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType));
if (extra is not Video && extra is not Audio)
@@ -2786,6 +2891,7 @@ namespace Emby.Server.Implementations.Library
extra.ParentId = Guid.Empty;
extra.OwnerId = owner.Id;
+ extra.IsInMixedFolder = isInMixedFolder;
return extra;
}
}
@@ -2950,7 +3056,7 @@ namespace Emby.Server.Implementations.Library
{
var path = Path.Combine(virtualFolderPath, collectionType.ToString()!.ToLowerInvariant() + ".collection"); // Can't be null with legal values?
- await File.WriteAllBytesAsync(path, []).ConfigureAwait(false);
+ FileHelper.CreateEmpty(path);
}
CollectionFolder.SaveLibraryOptions(virtualFolderPath, options);
@@ -2965,10 +3071,10 @@ namespace Emby.Server.Implementations.Library
}
finally
{
+ await ValidateTopLibraryFolders(CancellationToken.None).ConfigureAwait(false);
+
if (refreshLibrary)
{
- await ValidateTopLibraryFolders(CancellationToken.None).ConfigureAwait(false);
-
StartScanInBackground();
}
else
@@ -2993,19 +3099,28 @@ namespace Emby.Server.Implementations.Library
if (personEntity is null)
{
- var path = Person.GetPath(person.Name);
- personEntity = new Person()
+ try
{
- Name = person.Name,
- Id = GetItemByNameId<Person>(path),
- DateCreated = DateTime.UtcNow,
- DateModified = DateTime.UtcNow,
- Path = path
- };
-
- personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey();
- saveEntity = true;
- createEntity = true;
+ 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();
+ saveEntity = true;
+ createEntity = true;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to create person {Name}", person.Name);
+ continue;
+ }
}
foreach (var id in person.ProviderIds)
@@ -3039,6 +3154,8 @@ namespace Emby.Server.Implementations.Library
}
await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
+ personEntity.DateLastSaved = DateTime.UtcNow;
+
CreateItems([personEntity], null, CancellationToken.None);
}
}
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index c6cfd5391..c667fb060 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -226,6 +226,11 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />>
public MediaProtocol GetPathProtocol(string path)
{
+ if (string.IsNullOrEmpty(path))
+ {
+ return MediaProtocol.File;
+ }
+
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
{
return MediaProtocol.Rtsp;
@@ -379,7 +384,7 @@ namespace Emby.Server.Implementations.Library
var culture = _localizationManager.FindLanguageInfo(language);
if (culture is not null)
{
- return culture.ThreeLetterISOLanguageNames;
+ return culture.Name.Contains('-', StringComparison.OrdinalIgnoreCase) ? [culture.Name] : culture.ThreeLetterISOLanguageNames;
}
return [language];
@@ -657,7 +662,7 @@ namespace Emby.Server.Implementations.Library
}
catch (Exception ex)
{
- _logger.LogDebug(ex, "_jsonSerializer.DeserializeFromFile threw an exception.");
+ _logger.LogDebug(ex, "Error parsing cached media info.");
}
finally
{
@@ -681,17 +686,17 @@ namespace Emby.Server.Implementations.Library
mediaInfo = await _mediaEncoder.GetMediaInfo(
new MediaInfoRequest
- {
- MediaSource = mediaSource,
- MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video,
- ExtractChapters = false
- },
+ {
+ MediaSource = mediaSource,
+ MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video,
+ ExtractChapters = false
+ },
cancellationToken).ConfigureAwait(false);
if (cacheFilePath is not null)
{
Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath));
- FileStream createStream = File.Create(cacheFilePath);
+ FileStream createStream = AsyncFile.Create(cacheFilePath);
await using (createStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false);
diff --git a/Emby.Server.Implementations/Library/MusicManager.cs b/Emby.Server.Implementations/Library/MusicManager.cs
index 28cf69500..e19ad3ef6 100644
--- a/Emby.Server.Implementations/Library/MusicManager.cs
+++ b/Emby.Server.Implementations/Library/MusicManager.cs
@@ -28,7 +28,9 @@ namespace Emby.Server.Implementations.Library
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
{
- return GetInstantMixFromGenres(item.Genres, user, dtoOptions);
+ var instantMixItems = GetInstantMixFromGenres(item.Genres, user, dtoOptions);
+
+ return [item, .. instantMixItems.Where(i => !i.Id.Equals(item.Id))];
}
/// <inheritdoc />
@@ -45,11 +47,14 @@ namespace Emby.Server.Implementations.Library
public IReadOnlyList<BaseItem> GetInstantMixFromFolder(Folder item, User? user, DtoOptions dtoOptions)
{
var genres = item
- .GetRecursiveChildren(user, new InternalItemsQuery(user)
- {
- IncludeItemTypes = [BaseItemKind.Audio],
- DtoOptions = dtoOptions
- })
+ .GetRecursiveChildren(
+ user,
+ new InternalItemsQuery(user)
+ {
+ IncludeItemTypes = [BaseItemKind.Audio],
+ DtoOptions = dtoOptions
+ },
+ out _)
.Cast<Audio>()
.SelectMany(i => i.Genres)
.Concat(item.Genres)
diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs
index 83a6df964..a9b7a1274 100644
--- a/Emby.Server.Implementations/Library/PathManager.cs
+++ b/Emby.Server.Implementations/Library/PathManager.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.IO;
using MediaBrowser.Common.Configuration;
@@ -36,7 +37,7 @@ public class PathManager : IPathManager
/// <inheritdoc />
public string GetAttachmentPath(string mediaSourceId, string fileName)
{
- return Path.Join(GetAttachmentFolderPath(mediaSourceId), fileName);
+ return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName);
}
/// <inheritdoc />
@@ -58,7 +59,7 @@ public class PathManager : IPathManager
/// <inheritdoc />
public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
{
- return Path.Join(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
+ return Path.Combine(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
}
/// <inheritdoc />
@@ -67,7 +68,34 @@ public class PathManager : IPathManager
var id = item.Id.ToString("D", CultureInfo.InvariantCulture).AsSpan();
return saveWithMedia
- ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(item.Path, ".trickplay"))
+ ? Path.Combine(item.ContainingFolderPath, Path.ChangeExtension(Path.GetFileName(item.Path), ".trickplay"))
: Path.Join(_config.ApplicationPaths.TrickplayPath, id[..2], id);
}
+
+ /// <inheritdoc/>
+ public string GetChapterImageFolderPath(BaseItem item)
+ {
+ return Path.Combine(item.GetInternalMetadataPath(), "chapters");
+ }
+
+ /// <inheritdoc/>
+ public string GetChapterImagePath(BaseItem item, long chapterPositionTicks)
+ {
+ var filename = item.DateModified.Ticks.ToString(CultureInfo.InvariantCulture) + "_" + chapterPositionTicks.ToString(CultureInfo.InvariantCulture) + ".jpg";
+
+ return Path.Combine(GetChapterImageFolderPath(item), filename);
+ }
+
+ /// <inheritdoc/>
+ public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item)
+ {
+ var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture);
+ return [
+ GetAttachmentFolderPath(mediaSourceId),
+ GetSubtitleFolderPath(mediaSourceId),
+ GetTrickplayDirectory(item, false),
+ GetTrickplayDirectory(item, true),
+ GetChapterImageFolderPath(item)
+ ];
+ }
}
diff --git a/Emby.Server.Implementations/Library/ResolverHelper.cs b/Emby.Server.Implementations/Library/ResolverHelper.cs
index c9e3a4daf..06aa772bd 100644
--- a/Emby.Server.Implementations/Library/ResolverHelper.cs
+++ b/Emby.Server.Implementations/Library/ResolverHelper.cs
@@ -136,23 +136,33 @@ namespace Emby.Server.Implementations.Library
if (config.UseFileCreationTimeForDateAdded)
{
- // directoryService.getFile may return null
- if (info is not null)
+ var fileCreationDate = info?.CreationTimeUtc;
+ if (fileCreationDate is not null)
{
- var dateCreated = info.CreationTimeUtc;
-
- if (dateCreated.Equals(DateTime.MinValue))
+ var dateCreated = fileCreationDate;
+ if (dateCreated == DateTime.MinValue)
{
dateCreated = DateTime.UtcNow;
}
- item.DateCreated = dateCreated;
+ item.DateCreated = dateCreated.Value;
}
}
else
{
item.DateCreated = DateTime.UtcNow;
}
+
+ if (info is not null && !info.IsDirectory)
+ {
+ item.Size = info.Length;
+ }
+
+ var fileModificationDate = info?.LastWriteTimeUtc;
+ if (fileModificationDate.HasValue)
+ {
+ item.DateModified = fileModificationDate.Value;
+ }
}
}
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
index f1aeb1340..98e8f5350 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs
@@ -369,13 +369,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
// We need to only look at the name of this actual item (not parents)
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
- if (!justName.IsEmpty)
+ var tmdbid = justName.GetAttributeValue("tmdbid");
+
+ // If not in a mixed folder and ID not found in folder path, check filename
+ if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
{
- // Check for TMDb id
- var tmdbid = justName.GetAttributeValue("tmdbid");
- item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
+ tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
}
+ item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
+
if (!string.IsNullOrEmpty(item.Path))
{
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)
@@ -405,6 +408,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
if (child.IsDirectory)
{
+ if (NamingOptions.AllExtrasTypesFolderNames.ContainsKey(filename))
+ {
+ continue;
+ }
+
if (IsDvdDirectory(child.FullName, filename, directoryService))
{
var movie = new T
@@ -456,12 +464,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
{
var videoPath = result.Items[0].Path;
var hasPhotos = photos.Any(i => !PhotoResolver.IsOwnedByResolvedMedia(videoPath, i.Name));
+ var hasOtherSubfolders = multiDiscFolders.Count > 0;
- if (!hasPhotos)
+ if (!hasPhotos && !hasOtherSubfolders)
{
var movie = (T)result.Items[0];
movie.IsInMixedFolder = false;
- movie.Name = Path.GetFileName(movie.ContainingFolderPath);
+ if (collectionType == CollectionType.movies || collectionType is null)
+ {
+ movie.Name = Path.GetFileName(movie.ContainingFolderPath);
+ }
+
return movie;
}
}
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index be1d96bf0..72c8d7a9d 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -80,6 +80,7 @@ namespace Emby.Server.Implementations.Library
var userId = user.InternalId;
var cacheKey = GetCacheKey(userId, item.Id);
_cache.AddOrUpdate(cacheKey, userData);
+ item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); // rehydrate the cached userdata
UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
{
@@ -159,7 +160,7 @@ namespace Emby.Server.Implementations.Library
};
}
- private UserItemData Map(UserData dto)
+ private static UserItemData Map(UserData dto)
{
return new UserItemData()
{
@@ -237,7 +238,10 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item)
{
- return GetUserData(user, item.Id, item.GetUserDataKeys());
+ return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
+ {
+ Key = item.GetUserDataKeys()[0],
+ };
}
/// <inheritdoc />
@@ -304,7 +308,7 @@ namespace Emby.Server.Implementations.Library
// ignore progress during the beginning
positionTicks = 0;
}
- else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= runtimeTicks)
+ else if (pctIn > _config.Configuration.MaxResumePct || positionTicks >= (runtimeTicks - TimeSpan.TicksPerSecond))
{
// mark as completed close to the end
positionTicks = 0;
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
index 87214c273..6fb53ff15 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -374,13 +374,22 @@ namespace Emby.Server.Implementations.Library
if (request.GroupItems)
{
- if (parents.OfType<ICollectionFolder>().All(i => i.CollectionType == CollectionType.tvshows))
+ var collectionType = parents
+ .Select(parent => parent switch
+ {
+ ICollectionFolder collectionFolder => collectionFolder.CollectionType,
+ UserView userView => userView.CollectionType,
+ _ => null
+ })
+ .FirstOrDefault(type => type is not null);
+
+ if (collectionType == CollectionType.tvshows)
{
query.Limit = limit;
return _libraryManager.GetLatestItemList(query, parents, CollectionType.tvshows);
}
- if (parents.OfType<ICollectionFolder>().All(i => i.CollectionType == CollectionType.music))
+ if (collectionType == CollectionType.music)
{
query.Limit = limit;
return _libraryManager.GetLatestItemList(query, parents, CollectionType.music);
diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs
index d51f9aaa7..a31d5ecca 100644
--- a/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs
+++ b/Emby.Server.Implementations/Library/Validators/ArtistsPostScanTask.cs
@@ -5,45 +5,44 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class ArtistsPostScanTask.
+/// </summary>
+public class ArtistsPostScanTask : ILibraryPostScanTask
{
/// <summary>
- /// Class ArtistsPostScanTask.
+ /// The _library manager.
/// </summary>
- public class ArtistsPostScanTask : ILibraryPostScanTask
- {
- /// <summary>
- /// The _library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
- private readonly ILogger<ArtistsValidator> _logger;
- private readonly IItemRepository _itemRepo;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger<ArtistsValidator> _logger;
+ private readonly IItemRepository _itemRepo;
- /// <summary>
- /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class.
- /// </summary>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="logger">The logger.</param>
- /// <param name="itemRepo">The item repository.</param>
- public ArtistsPostScanTask(
- ILibraryManager libraryManager,
- ILogger<ArtistsValidator> logger,
- IItemRepository itemRepo)
- {
- _libraryManager = libraryManager;
- _logger = logger;
- _itemRepo = itemRepo;
- }
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ArtistsPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="itemRepo">The item repository.</param>
+ public ArtistsPostScanTask(
+ ILibraryManager libraryManager,
+ ILogger<ArtistsValidator> logger,
+ IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
- /// <summary>
- /// Runs the specified progress.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
- {
- return new ArtistsValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
- }
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return new ArtistsValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
}
}
diff --git a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
index 7591e8391..7cc851b73 100644
--- a/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/ArtistsValidator.cs
@@ -10,102 +10,101 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class ArtistsValidator.
+/// </summary>
+public class ArtistsValidator
{
/// <summary>
- /// Class ArtistsValidator.
+ /// The library manager.
/// </summary>
- public class ArtistsValidator
- {
- /// <summary>
- /// The library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
+ private readonly ILibraryManager _libraryManager;
- /// <summary>
- /// The logger.
- /// </summary>
- private readonly ILogger<ArtistsValidator> _logger;
- private readonly IItemRepository _itemRepo;
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<ArtistsValidator> _logger;
+ private readonly IItemRepository _itemRepo;
- /// <summary>
- /// Initializes a new instance of the <see cref="ArtistsValidator" /> class.
- /// </summary>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="logger">The logger.</param>
- /// <param name="itemRepo">The item repository.</param>
- public ArtistsValidator(ILibraryManager libraryManager, ILogger<ArtistsValidator> logger, IItemRepository itemRepo)
- {
- _libraryManager = libraryManager;
- _logger = logger;
- _itemRepo = itemRepo;
- }
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ArtistsValidator" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="itemRepo">The item repository.</param>
+ public ArtistsValidator(ILibraryManager libraryManager, ILogger<ArtistsValidator> logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
- /// <summary>
- /// Runs the specified progress.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
- {
- var names = _itemRepo.GetAllArtistNames();
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var names = _itemRepo.GetAllArtistNames();
- var numComplete = 0;
- var count = names.Count;
+ var numComplete = 0;
+ var count = names.Count;
- foreach (var name in names)
+ foreach (var name in names)
+ {
+ try
{
- try
- {
- var item = _libraryManager.GetArtist(name);
-
- await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Don't clutter the log
- throw;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error refreshing {ArtistName}", name);
- }
-
- numComplete++;
- double percent = numComplete;
- percent /= count;
- percent *= 100;
+ var item = _libraryManager.GetArtist(name);
- progress.Report(percent);
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
}
-
- var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
+ catch (OperationCanceledException)
{
- IncludeItemTypes = new[] { BaseItemKind.MusicArtist },
- IsDeadArtist = true,
- IsLocked = false
- }).Cast<MusicArtist>().ToList();
-
- foreach (var item in deadEntities)
+ // Don't clutter the log
+ throw;
+ }
+ catch (Exception ex)
{
- if (!item.IsAccessedByName)
- {
- continue;
- }
+ _logger.LogError(ex, "Error refreshing {ArtistName}", name);
+ }
- _logger.LogInformation("Deleting dead {2} {0} {1}.", item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name, item.GetType().Name);
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
- _libraryManager.DeleteItem(
- item,
- new DeleteOptions
- {
- DeleteFileLocation = false
- },
- false);
+ progress.Report(percent);
+ }
+
+ var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.MusicArtist],
+ IsDeadArtist = true,
+ IsLocked = false
+ }).Cast<MusicArtist>().ToList();
+
+ foreach (var item in deadEntities)
+ {
+ if (!item.IsAccessedByName)
+ {
+ continue;
}
- progress.Report(100);
+ _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
+
+ _libraryManager.DeleteItem(
+ item,
+ new DeleteOptions
+ {
+ DeleteFileLocation = false
+ },
+ false);
}
+
+ progress.Report(100);
}
}
diff --git a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs
index 337b1afdd..e62c638ed 100644
--- a/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs
+++ b/Emby.Server.Implementations/Library/Validators/CollectionPostScanTask.cs
@@ -9,149 +9,145 @@ using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Querying;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class CollectionPostScanTask.
+/// </summary>
+public class CollectionPostScanTask : ILibraryPostScanTask
{
+ private readonly ILibraryManager _libraryManager;
+ private readonly ICollectionManager _collectionManager;
+ private readonly ILogger<CollectionPostScanTask> _logger;
+
/// <summary>
- /// Class CollectionPostScanTask.
+ /// Initializes a new instance of the <see cref="CollectionPostScanTask" /> class.
/// </summary>
- public class CollectionPostScanTask : ILibraryPostScanTask
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="collectionManager">The collection manager.</param>
+ /// <param name="logger">The logger.</param>
+ public CollectionPostScanTask(
+ ILibraryManager libraryManager,
+ ICollectionManager collectionManager,
+ ILogger<CollectionPostScanTask> logger)
{
- private readonly ILibraryManager _libraryManager;
- private readonly ICollectionManager _collectionManager;
- private readonly ILogger<CollectionPostScanTask> _logger;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="CollectionPostScanTask" /> class.
- /// </summary>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="collectionManager">The collection manager.</param>
- /// <param name="logger">The logger.</param>
- public CollectionPostScanTask(
- ILibraryManager libraryManager,
- ICollectionManager collectionManager,
- ILogger<CollectionPostScanTask> logger)
- {
- _libraryManager = libraryManager;
- _collectionManager = collectionManager;
- _logger = logger;
- }
+ _libraryManager = libraryManager;
+ _collectionManager = collectionManager;
+ _logger = logger;
+ }
- /// <summary>
- /// Runs the specified progress.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
- {
- var collectionNameMoviesMap = new Dictionary<string, HashSet<Guid>>();
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var collectionNameMoviesMap = new Dictionary<string, HashSet<Guid>>();
- foreach (var library in _libraryManager.RootFolder.Children)
+ foreach (var library in _libraryManager.RootFolder.Children)
+ {
+ if (!_libraryManager.GetLibraryOptions(library).AutomaticallyAddToCollection)
{
- if (!_libraryManager.GetLibraryOptions(library).AutomaticallyAddToCollection)
- {
- continue;
- }
+ continue;
+ }
- var startIndex = 0;
- var pagesize = 1000;
+ var startIndex = 0;
+ var pagesize = 1000;
- while (true)
+ while (true)
+ {
+ var movies = _libraryManager.GetItemList(new InternalItemsQuery
{
- var movies = _libraryManager.GetItemList(new InternalItemsQuery
- {
- MediaTypes = new[] { MediaType.Video },
- IncludeItemTypes = new[] { BaseItemKind.Movie },
- IsVirtualItem = false,
- OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
- Parent = library,
- StartIndex = startIndex,
- Limit = pagesize,
- Recursive = true
- });
-
- foreach (var m in movies)
+ MediaTypes = [MediaType.Video],
+ IncludeItemTypes = [BaseItemKind.Movie],
+ IsVirtualItem = false,
+ OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)],
+ Parent = library,
+ StartIndex = startIndex,
+ Limit = pagesize,
+ Recursive = true
+ });
+
+ foreach (var m in movies)
+ {
+ if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName))
{
- if (m is Movie movie && !string.IsNullOrEmpty(movie.CollectionName))
+ if (collectionNameMoviesMap.TryGetValue(movie.CollectionName, out var movieList))
{
- if (collectionNameMoviesMap.TryGetValue(movie.CollectionName, out var movieList))
- {
- movieList.Add(movie.Id);
- }
- else
- {
- collectionNameMoviesMap[movie.CollectionName] = new HashSet<Guid> { movie.Id };
- }
+ movieList.Add(movie.Id);
+ }
+ else
+ {
+ collectionNameMoviesMap[movie.CollectionName] = new HashSet<Guid> { movie.Id };
}
}
+ }
- if (movies.Count < pagesize)
- {
- break;
- }
-
- startIndex += pagesize;
+ if (movies.Count < pagesize)
+ {
+ break;
}
+
+ startIndex += pagesize;
}
+ }
- var numComplete = 0;
- var count = collectionNameMoviesMap.Count;
+ var numComplete = 0;
+ var count = collectionNameMoviesMap.Count;
- if (count == 0)
- {
- progress.Report(100);
- return;
- }
+ if (count == 0)
+ {
+ progress.Report(100);
+ return;
+ }
- var boxSets = _libraryManager.GetItemList(new InternalItemsQuery
- {
- IncludeItemTypes = new[] { BaseItemKind.BoxSet },
- CollapseBoxSetItems = false,
- Recursive = true
- });
+ var boxSets = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.BoxSet],
+ CollapseBoxSetItems = false,
+ Recursive = true
+ });
- foreach (var (collectionName, movieIds) in collectionNameMoviesMap)
+ foreach (var (collectionName, movieIds) in collectionNameMoviesMap)
+ {
+ try
{
- try
+ var boxSet = boxSets.FirstOrDefault(b => b?.Name == collectionName) as BoxSet;
+ if (boxSet is null)
{
- var boxSet = boxSets.FirstOrDefault(b => b?.Name == collectionName) as BoxSet;
- if (boxSet is null)
+ // won't automatically create collection if only one movie in it
+ if (movieIds.Count >= 2)
{
- // won't automatically create collection if only one movie in it
- if (movieIds.Count >= 2)
+ boxSet = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions
{
- boxSet = await _collectionManager.CreateCollectionAsync(new CollectionCreationOptions
- {
- Name = collectionName,
- IsLocked = true
- });
+ Name = collectionName,
+ }).ConfigureAwait(false);
- await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds);
- }
+ await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds).ConfigureAwait(false);
}
- else
- {
- await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds);
- }
-
- numComplete++;
- double percent = numComplete;
- percent /= count;
- percent *= 100;
-
- progress.Report(percent);
}
- catch (Exception ex)
+ else
{
- _logger.LogError(ex, "Error refreshing {CollectionName} with {@MovieIds}", collectionName, movieIds);
+ await _collectionManager.AddToCollectionAsync(boxSet.Id, movieIds).ConfigureAwait(false);
}
- }
- progress.Report(100);
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error refreshing {CollectionName} with {@MovieIds}", collectionName, movieIds);
+ }
}
+
+ progress.Report(100);
}
}
diff --git a/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs
index d21d2887b..5097e0073 100644
--- a/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs
+++ b/Emby.Server.Implementations/Library/Validators/GenresPostScanTask.cs
@@ -5,45 +5,44 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class GenresPostScanTask.
+/// </summary>
+public class GenresPostScanTask : ILibraryPostScanTask
{
/// <summary>
- /// Class GenresPostScanTask.
+ /// The _library manager.
/// </summary>
- public class GenresPostScanTask : ILibraryPostScanTask
- {
- /// <summary>
- /// The _library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
- private readonly ILogger<GenresValidator> _logger;
- private readonly IItemRepository _itemRepo;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger<GenresValidator> _logger;
+ private readonly IItemRepository _itemRepo;
- /// <summary>
- /// Initializes a new instance of the <see cref="GenresPostScanTask" /> class.
- /// </summary>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="logger">The logger.</param>
- /// <param name="itemRepo">The item repository.</param>
- public GenresPostScanTask(
- ILibraryManager libraryManager,
- ILogger<GenresValidator> logger,
- IItemRepository itemRepo)
- {
- _libraryManager = libraryManager;
- _logger = logger;
- _itemRepo = itemRepo;
- }
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GenresPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="itemRepo">The item repository.</param>
+ public GenresPostScanTask(
+ ILibraryManager libraryManager,
+ ILogger<GenresValidator> logger,
+ IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
- /// <summary>
- /// Runs the specified progress.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
- {
- return new GenresValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
- }
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return new GenresValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
}
}
diff --git a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs
index 364770fcd..fbfc9f7d5 100644
--- a/Emby.Server.Implementations/Library/Validators/GenresValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/GenresValidator.cs
@@ -8,97 +8,96 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class GenresValidator.
+/// </summary>
+public class GenresValidator
{
/// <summary>
- /// Class GenresValidator.
+ /// The library manager.
/// </summary>
- public class GenresValidator
- {
- /// <summary>
- /// The library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
- private readonly IItemRepository _itemRepo;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IItemRepository _itemRepo;
- /// <summary>
- /// The logger.
- /// </summary>
- private readonly ILogger<GenresValidator> _logger;
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<GenresValidator> _logger;
- /// <summary>
- /// Initializes a new instance of the <see cref="GenresValidator"/> class.
- /// </summary>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="logger">The logger.</param>
- /// <param name="itemRepo">The item repository.</param>
- public GenresValidator(ILibraryManager libraryManager, ILogger<GenresValidator> logger, IItemRepository itemRepo)
- {
- _libraryManager = libraryManager;
- _logger = logger;
- _itemRepo = itemRepo;
- }
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GenresValidator"/> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="itemRepo">The item repository.</param>
+ public GenresValidator(ILibraryManager libraryManager, ILogger<GenresValidator> logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
- /// <summary>
- /// Runs the specified progress.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
- {
- var names = _itemRepo.GetGenreNames();
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var names = _itemRepo.GetGenreNames();
- var numComplete = 0;
- var count = names.Count;
+ var numComplete = 0;
+ var count = names.Count;
- foreach (var name in names)
+ foreach (var name in names)
+ {
+ try
{
- try
- {
- var item = _libraryManager.GetGenre(name);
+ var item = _libraryManager.GetGenre(name);
- await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Don't clutter the log
- throw;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error refreshing {GenreName}", name);
- }
-
- numComplete++;
- double percent = numComplete;
- percent /= count;
- percent *= 100;
-
- progress.Report(percent);
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
}
-
- var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
+ catch (OperationCanceledException)
{
- IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre],
- IsDeadGenre = true,
- IsLocked = false
- });
-
- foreach (var item in deadEntities)
+ // Don't clutter the log
+ throw;
+ }
+ catch (Exception ex)
{
- _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
-
- _libraryManager.DeleteItem(
- item,
- new DeleteOptions
- {
- DeleteFileLocation = false
- },
- false);
+ _logger.LogError(ex, "Error refreshing {GenreName}", name);
}
- progress.Report(100);
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
}
+
+ var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Genre, BaseItemKind.MusicGenre],
+ IsDeadGenre = true,
+ IsLocked = false
+ });
+
+ foreach (var item in deadEntities)
+ {
+ _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
+
+ _libraryManager.DeleteItem(
+ item,
+ new DeleteOptions
+ {
+ DeleteFileLocation = false
+ },
+ false);
+ }
+
+ progress.Report(100);
}
}
diff --git a/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs
index be119866b..76658a81b 100644
--- a/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs
+++ b/Emby.Server.Implementations/Library/Validators/MusicGenresPostScanTask.cs
@@ -5,45 +5,44 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class MusicGenresPostScanTask.
+/// </summary>
+public class MusicGenresPostScanTask : ILibraryPostScanTask
{
/// <summary>
- /// Class MusicGenresPostScanTask.
+ /// The library manager.
/// </summary>
- public class MusicGenresPostScanTask : ILibraryPostScanTask
- {
- /// <summary>
- /// The library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
- private readonly ILogger<MusicGenresValidator> _logger;
- private readonly IItemRepository _itemRepo;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger<MusicGenresValidator> _logger;
+ private readonly IItemRepository _itemRepo;
- /// <summary>
- /// Initializes a new instance of the <see cref="MusicGenresPostScanTask" /> class.
- /// </summary>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="logger">The logger.</param>
- /// <param name="itemRepo">The item repository.</param>
- public MusicGenresPostScanTask(
- ILibraryManager libraryManager,
- ILogger<MusicGenresValidator> logger,
- IItemRepository itemRepo)
- {
- _libraryManager = libraryManager;
- _logger = logger;
- _itemRepo = itemRepo;
- }
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MusicGenresPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="itemRepo">The item repository.</param>
+ public MusicGenresPostScanTask(
+ ILibraryManager libraryManager,
+ ILogger<MusicGenresValidator> logger,
+ IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
- /// <summary>
- /// Runs the specified progress.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
- {
- return new MusicGenresValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
- }
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return new MusicGenresValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
}
}
diff --git a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs
index 1ecf4c87c..6203bce2b 100644
--- a/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/MusicGenresValidator.cs
@@ -5,77 +5,76 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class MusicGenresValidator.
+/// </summary>
+public class MusicGenresValidator
{
/// <summary>
- /// Class MusicGenresValidator.
+ /// The library manager.
/// </summary>
- public class MusicGenresValidator
- {
- /// <summary>
- /// The library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
+ private readonly ILibraryManager _libraryManager;
- /// <summary>
- /// The logger.
- /// </summary>
- private readonly ILogger<MusicGenresValidator> _logger;
- private readonly IItemRepository _itemRepo;
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<MusicGenresValidator> _logger;
+ private readonly IItemRepository _itemRepo;
- /// <summary>
- /// Initializes a new instance of the <see cref="MusicGenresValidator" /> class.
- /// </summary>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="logger">The logger.</param>
- /// <param name="itemRepo">The item repository.</param>
- public MusicGenresValidator(ILibraryManager libraryManager, ILogger<MusicGenresValidator> logger, IItemRepository itemRepo)
- {
- _libraryManager = libraryManager;
- _logger = logger;
- _itemRepo = itemRepo;
- }
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MusicGenresValidator" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="itemRepo">The item repository.</param>
+ public MusicGenresValidator(ILibraryManager libraryManager, ILogger<MusicGenresValidator> logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
- /// <summary>
- /// Runs the specified progress.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
- {
- var names = _itemRepo.GetMusicGenreNames();
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var names = _itemRepo.GetMusicGenreNames();
- var numComplete = 0;
- var count = names.Count;
+ var numComplete = 0;
+ var count = names.Count;
- foreach (var name in names)
+ foreach (var name in names)
+ {
+ try
{
- try
- {
- var item = _libraryManager.GetMusicGenre(name);
+ var item = _libraryManager.GetMusicGenre(name);
- await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Don't clutter the log
- throw;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error refreshing {GenreName}", name);
- }
-
- numComplete++;
- double percent = numComplete;
- percent /= count;
- percent *= 100;
-
- progress.Report(percent);
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Don't clutter the log
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error refreshing {GenreName}", name);
}
- progress.Report(100);
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
}
+
+ progress.Report(100);
}
}
diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
index 725b8f76c..f9a6f0d19 100644
--- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
@@ -1,5 +1,5 @@
using System;
-using System.Globalization;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
@@ -9,119 +9,112 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class PeopleValidator.
+/// </summary>
+public class PeopleValidator
{
/// <summary>
- /// Class PeopleValidator.
+ /// The _library manager.
/// </summary>
- public class PeopleValidator
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// The _logger.
+ /// </summary>
+ private readonly ILogger _logger;
+
+ private readonly IFileSystem _fileSystem;
+
+ /// <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)
{
- /// <summary>
- /// The _library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
-
- /// <summary>
- /// The _logger.
- /// </summary>
- private readonly ILogger _logger;
-
- private readonly IFileSystem _fileSystem;
-
- /// <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)
- {
- _libraryManager = libraryManager;
- _logger = logger;
- _fileSystem = fileSystem;
- }
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ }
- /// <summary>
- /// Validates the people.
- /// </summary>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <param name="progress">The progress.</param>
- /// <returns>Task.</returns>
- public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
- {
- var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
+ /// <summary>
+ /// Validates the people.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="progress">The progress.</param>
+ /// <returns>Task.</returns>
+ public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
+ {
+ var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
- var numComplete = 0;
+ var numComplete = 0;
- var numPeople = people.Count;
+ var numPeople = people.Count;
- _logger.LogDebug("Will refresh {0} people", numPeople);
+ IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
- foreach (var person in people)
- {
- cancellationToken.ThrowIfCancellationRequested();
+ _logger.LogDebug("Will refresh {Amount} people", numPeople);
- try
- {
- var item = _libraryManager.GetPerson(person);
- if (item is null)
- {
- _logger.LogWarning("Failed to get person: {Name}", person);
- continue;
- }
-
- var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
- {
- ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
- MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
- };
-
- await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception ex)
+ foreach (var person in people)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ try
+ {
+ var item = _libraryManager.GetPerson(person);
+ if (item is null)
{
- _logger.LogError(ex, "Error validating IBN entry {Person}", person);
+ _logger.LogWarning("Failed to get person: {Name}", person);
+ continue;
}
- // Update progress
- numComplete++;
- double percent = numComplete;
- percent /= numPeople;
+ var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
+ {
+ ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
+ MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
+ };
- progress.Report(100 * percent);
+ await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
}
-
- var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
+ catch (OperationCanceledException)
{
- IncludeItemTypes = [BaseItemKind.Person],
- IsDeadPerson = true,
- IsLocked = false
- });
-
- foreach (var item in deadEntities)
+ throw;
+ }
+ catch (Exception ex)
{
- _logger.LogInformation(
- "Deleting dead {2} {0} {1}.",
- item.Id.ToString("N", CultureInfo.InvariantCulture),
- item.Name,
- item.GetType().Name);
-
- _libraryManager.DeleteItem(
- item,
- new DeleteOptions
- {
- DeleteFileLocation = false
- },
- false);
+ _logger.LogError(ex, "Error validating IBN entry {Person}", person);
}
- progress.Report(100);
+ // Update progress
+ numComplete++;
+ double percent = numComplete;
+ percent /= numPeople;
- _logger.LogInformation("People validation complete");
+ subProgress.Report(100 * percent);
}
+
+ var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Person],
+ IsDeadPerson = true,
+ IsLocked = false
+ });
+
+ subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
+
+ var i = 0;
+ foreach (var item in deadEntities.Chunk(500))
+ {
+ _libraryManager.DeleteItemsUnsafeFast(item);
+ subProgress.Report(100f / deadEntities.Count * (i++ * 100));
+ }
+
+ progress.Report(100);
+
+ _logger.LogInformation("People validation complete");
}
}
diff --git a/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs b/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs
index c682b156b..67c56c104 100644
--- a/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs
+++ b/Emby.Server.Implementations/Library/Validators/StudiosPostScanTask.cs
@@ -5,46 +5,45 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class MusicGenresPostScanTask.
+/// </summary>
+public class StudiosPostScanTask : ILibraryPostScanTask
{
/// <summary>
- /// Class MusicGenresPostScanTask.
+ /// The _library manager.
/// </summary>
- public class StudiosPostScanTask : ILibraryPostScanTask
- {
- /// <summary>
- /// The _library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
+ private readonly ILibraryManager _libraryManager;
- private readonly ILogger<StudiosValidator> _logger;
- private readonly IItemRepository _itemRepo;
+ private readonly ILogger<StudiosValidator> _logger;
+ private readonly IItemRepository _itemRepo;
- /// <summary>
- /// Initializes a new instance of the <see cref="StudiosPostScanTask" /> class.
- /// </summary>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="logger">The logger.</param>
- /// <param name="itemRepo">The item repository.</param>
- public StudiosPostScanTask(
- ILibraryManager libraryManager,
- ILogger<StudiosValidator> logger,
- IItemRepository itemRepo)
- {
- _libraryManager = libraryManager;
- _logger = logger;
- _itemRepo = itemRepo;
- }
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StudiosPostScanTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="itemRepo">The item repository.</param>
+ public StudiosPostScanTask(
+ ILibraryManager libraryManager,
+ ILogger<StudiosValidator> logger,
+ IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
- /// <summary>
- /// Runs the specified progress.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
- {
- return new StudiosValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
- }
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ return new StudiosValidator(_libraryManager, _logger, _itemRepo).Run(progress, cancellationToken);
}
}
diff --git a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs
index 26bc49c1f..5b87e4d9d 100644
--- a/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/StudiosValidator.cs
@@ -8,98 +8,97 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.Library.Validators
+namespace Emby.Server.Implementations.Library.Validators;
+
+/// <summary>
+/// Class StudiosValidator.
+/// </summary>
+public class StudiosValidator
{
/// <summary>
- /// Class StudiosValidator.
+ /// The library manager.
/// </summary>
- public class StudiosValidator
- {
- /// <summary>
- /// The library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
+ private readonly ILibraryManager _libraryManager;
- private readonly IItemRepository _itemRepo;
+ private readonly IItemRepository _itemRepo;
- /// <summary>
- /// The logger.
- /// </summary>
- private readonly ILogger<StudiosValidator> _logger;
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<StudiosValidator> _logger;
- /// <summary>
- /// Initializes a new instance of the <see cref="StudiosValidator" /> class.
- /// </summary>
- /// <param name="libraryManager">The library manager.</param>
- /// <param name="logger">The logger.</param>
- /// <param name="itemRepo">The item repository.</param>
- public StudiosValidator(ILibraryManager libraryManager, ILogger<StudiosValidator> logger, IItemRepository itemRepo)
- {
- _libraryManager = libraryManager;
- _logger = logger;
- _itemRepo = itemRepo;
- }
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StudiosValidator" /> class.
+ /// </summary>
+ /// <param name="libraryManager">The library manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="itemRepo">The item repository.</param>
+ public StudiosValidator(ILibraryManager libraryManager, ILogger<StudiosValidator> logger, IItemRepository itemRepo)
+ {
+ _libraryManager = libraryManager;
+ _logger = logger;
+ _itemRepo = itemRepo;
+ }
- /// <summary>
- /// Runs the specified progress.
- /// </summary>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
- {
- var names = _itemRepo.GetStudioNames();
+ /// <summary>
+ /// Runs the specified progress.
+ /// </summary>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var names = _itemRepo.GetStudioNames();
- var numComplete = 0;
- var count = names.Count;
+ var numComplete = 0;
+ var count = names.Count;
- foreach (var name in names)
+ foreach (var name in names)
+ {
+ try
{
- try
- {
- var item = _libraryManager.GetStudio(name);
+ var item = _libraryManager.GetStudio(name);
- await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // Don't clutter the log
- throw;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error refreshing {StudioName}", name);
- }
-
- numComplete++;
- double percent = numComplete;
- percent /= count;
- percent *= 100;
-
- progress.Report(percent);
+ await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
}
-
- var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
+ catch (OperationCanceledException)
{
- IncludeItemTypes = new[] { BaseItemKind.Studio },
- IsDeadStudio = true,
- IsLocked = false
- });
-
- foreach (var item in deadEntities)
+ // Don't clutter the log
+ throw;
+ }
+ catch (Exception ex)
{
- _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
-
- _libraryManager.DeleteItem(
- item,
- new DeleteOptions
- {
- DeleteFileLocation = false
- },
- false);
+ _logger.LogError(ex, "Error refreshing {StudioName}", name);
}
- progress.Report(100);
+ numComplete++;
+ double percent = numComplete;
+ percent /= count;
+ percent *= 100;
+
+ progress.Report(percent);
}
+
+ var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Studio],
+ IsDeadStudio = true,
+ IsLocked = false
+ });
+
+ foreach (var item in deadEntities)
+ {
+ _logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
+
+ _libraryManager.DeleteItem(
+ item,
+ new DeleteOptions
+ {
+ DeleteFileLocation = false
+ },
+ false);
+ }
+
+ progress.Report(100);
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json
index 2d29eb5bf..a92148caf 100644
--- a/Emby.Server.Implementations/Localization/Core/ar.json
+++ b/Emby.Server.Implementations/Localization/Core/ar.json
@@ -125,8 +125,8 @@
"TaskKeyframeExtractor": "مستخرج الإطار الرئيسي",
"External": "خارجي",
"HearingImpaired": "ضعاف السمع",
- "TaskRefreshTrickplayImages": "توليد صور Trickplay",
- "TaskRefreshTrickplayImagesDescription": "يُنشئ معاينات Trickplay لمقاطع الفيديو في المكتبات المُمكّنة.",
+ "TaskRefreshTrickplayImages": "توليد صور المعاينة السريعة",
+ "TaskRefreshTrickplayImagesDescription": "يُولّد معاينات تنقل سريع لمقاطع الفيديو ضمن المكتبات المفعّلة.",
"TaskCleanCollectionsAndPlaylists": "حذف المجموعات وقوائم التشغيل",
"TaskCleanCollectionsAndPlaylistsDescription": "حذف عناصر من المجموعات وقوائم التشغيل التي لم تعد موجودة.",
"TaskAudioNormalization": "تسوية الصوت",
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "فحص مقاطع الوسائط",
"TaskExtractMediaSegmentsDescription": "يستخرج مقاطع وسائط من إضافات MediaSegment المُفعّلة.",
"TaskMoveTrickplayImages": "تغيير مكان صور المعاينة السريعة",
- "TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة."
+ "TaskMoveTrickplayImagesDescription": "تُنقل ملفات التشغيل السريع الحالية بناءً على إعدادات المكتبة.",
+ "CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
+ "CleanupUserDataTaskDescription": "مسح جميع بيانات المستخدم (حالة المشاهدة، والحالة المفضلة وما إلى ذلك) من الوسائط التي لم تعد موجودة لمدة 90 يومًا على الأقل."
}
diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json
index d5da04fb9..29847048c 100644
--- a/Emby.Server.Implementations/Localization/Core/be.json
+++ b/Emby.Server.Implementations/Localization/Core/be.json
@@ -1,16 +1,16 @@
{
"Sync": "Сінхранізаваць",
- "Playlists": "Спісы прайгравання",
- "Latest": "Апошні",
+ "Playlists": "Плэй-лісты",
+ "Latest": "Апошняе",
"LabelIpAddressValue": "IP-адрас: {0}",
- "ItemAddedWithName": "{0} быў дададзены ў бібліятэку",
+ "ItemAddedWithName": "{0} даданы ў бібліятэку",
"MessageApplicationUpdated": "Сервер Jellyfin абноўлены",
- "NotificationOptionApplicationUpdateInstalled": "Абнаўленне прыкладання ўсталявана",
+ "NotificationOptionApplicationUpdateInstalled": "Абнаўленне праграмы ўсталявана",
"PluginInstalledWithName": "{0} быў усталяваны",
"UserCreatedWithName": "Карыстальнік {0} быў створаны",
"Albums": "Альбомы",
- "Application": "Прыкладанне",
- "AuthenticationSucceededWithUserName": "{0} паспяхова аўтэнтыфікаваны",
+ "Application": "Праграма",
+ "AuthenticationSucceededWithUserName": "{0} паспяхова аўтарызаваны",
"Channels": "Каналы",
"ChapterNameValue": "Раздзел {0}",
"Collections": "Калекцыі",
@@ -29,18 +29,18 @@
"HeaderAlbumArtists": "Выканаўцы альбома",
"LabelRunningTimeValue": "Працягласць: {0}",
"HomeVideos": "Хатнія відэа",
- "ItemRemovedWithName": "{0} быў выдалены з бібліятэкі",
- "MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да {0}",
+ "ItemRemovedWithName": "{0} выдалены з бібліятэкі",
+ "MessageApplicationUpdatedTo": "Сервер Jellyfin абноўлены да версіі {0}",
"Movies": "Фільмы",
"Music": "Музыка",
"MusicVideos": "Музычныя кліпы",
- "NameInstallFailed": "Устаноўка {0} не атрымалася",
+ "NameInstallFailed": "Усталяванне {0} не атрымалася",
"NameSeasonNumber": "Сезон {0}",
- "NotificationOptionApplicationUpdateAvailable": "Даступна абнаўленне прыкладання",
+ "NotificationOptionApplicationUpdateAvailable": "Даступна абнаўленне праграмы",
"NotificationOptionPluginInstalled": "Плагін усталяваны",
- "NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна усталявана",
+ "NotificationOptionPluginUpdateInstalled": "Абнаўленне плагіна ўсталявана",
"NotificationOptionServerRestartRequired": "Патрабуецца перазапуск сервера",
- "Photos": "Фатаграфіі",
+ "Photos": "Фотаздымкі",
"Plugin": "Плагін",
"PluginUninstalledWithName": "{0} быў выдалены",
"PluginUpdatedWithName": "{0} быў абноўлены",
@@ -54,16 +54,16 @@
"Artists": "Выканаўцы",
"UserOfflineFromDevice": "{0} адлучыўся ад {1}",
"UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}",
- "TaskCleanActivityLogDescription": "Выдаляе старэйшыя за зададзены ўзрост запісы ў журнале актыўнасці.",
+ "TaskCleanActivityLogDescription": "Выдаляе запісы старэйшыя за зададзены ўзрост ў журнале актыўнасці.",
"TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.",
"TaskCleanLogsDescription": "Выдаляе файлы журналу, якім больш за {0} дзён.",
- "TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія настроены на аўтаматычнае абнаўленне.",
+ "TaskUpdatePluginsDescription": "Спампоўвае і ўсталёўвае абнаўленні для плагінаў, якія сканфігураваныя на аўтаматычнае абнаўленне.",
"TaskRefreshChannelsDescription": "Абнаўляе інфармацыю аб інтэрнэт-канале.",
- "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субтытры на аснове канфігурацыі метададзеных.",
- "TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых змяненняў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць прадукцыйнасць.",
+ "TaskDownloadMissingSubtitlesDescription": "Шукае ў інтэрнэце адсутныя субцітры на аснове канфігурацыі метададзеных.",
+ "TaskOptimizeDatabaseDescription": "Ушчыльняе базу дадзеных і скарачае вольную прастору. Выкананне гэтай задачы пасля сканавання бібліятэкі або ўнясення іншых зменаў, якія прадугледжваюць мадыфікацыю базы дадзеных, можа палепшыць выдайнасць.",
"TaskKeyframeExtractor": "Экстрактар ключавых кадраў",
- "TasksApplicationCategory": "Прыкладанне",
- "AppDeviceValues": "Прыкладанне: {0}, Прылада: {1}",
+ "TasksApplicationCategory": "Праграма",
+ "AppDeviceValues": "Праграма: {0}, Прылада: {1}",
"Books": "Кнігі",
"CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}",
"DeviceOfflineWithName": "{0} адлучыўся",
@@ -74,7 +74,7 @@
"HeaderFavoriteArtists": "Абраныя выканаўцы",
"HearingImpaired": "Са слабым слыхам",
"Inherit": "Атрымаць у спадчыну",
- "MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера {0} абноўлена",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Канфігурацыя сервера (секцыя {0}) абноўлена",
"MessageServerConfigurationUpdated": "Канфігурацыя сервера абноўлена",
"MixedContent": "Змешаны змест",
"NameSeasonUnknown": "Невядомы сезон",
@@ -92,48 +92,50 @@
"NotificationOptionVideoPlaybackStopped": "Прайграванне відэа спынена",
"ScheduledTaskFailedWithName": "{0} не атрымалася",
"ScheduledTaskStartedWithName": "{0} пачалося",
- "ServerNameNeedsToBeRestarted": "{0} трэба перазапусціць",
+ "ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску",
"Shows": "Шоу",
"StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.",
"SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}",
- "TvShows": "ТБ-шоу",
+ "TvShows": "Тэлепраграма",
"Undefined": "Нявызначана",
"UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны",
"UserOnlineFromDevice": "{0} падключаны з {1}",
"UserPasswordChangedWithName": "Пароль быў зменены для карыстальніка {0}",
- "UserStartedPlayingItemWithValues": "{0} грае {1} на {2}",
+ "UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}",
"UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}",
"ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку",
"ValueSpecialEpisodeName": "Спецэпізод - {0}",
"VersionNumber": "Версія {0}",
"TasksMaintenanceCategory": "Абслугоўванне",
- "TasksLibraryCategory": "Медыятэка",
+ "TasksLibraryCategory": "Бібліятэка",
"TasksChannelsCategory": "Інтэрнэт-каналы",
"TaskCleanActivityLog": "Ачысціць журнал актыўнасці",
"TaskCleanCache": "Ачысціць кэш",
"TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.",
- "TaskRefreshChapterImages": "Выняць выявы раздзелаў",
- "TaskRefreshLibrary": "Сканіраваць медыятэку",
- "TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.",
- "TaskCleanLogs": "Ачысціць часопіс",
- "TaskRefreshPeople": "Абнавіць людзей",
+ "TaskRefreshChapterImages": "Вынуць выявы раздзелаў",
+ "TaskRefreshLibrary": "Сканаваць бібліятэку",
+ "TaskRefreshLibraryDescription": "Скануе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.",
+ "TaskCleanLogs": "Ачысціць журнал",
+ "TaskRefreshPeople": "Абнавіць выканаўцаў",
"TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.",
"TaskUpdatePlugins": "Абнавіць плагіны",
"TaskCleanTranscode": "Ачысціць каталог перакадзіравання",
"TaskCleanTranscodeDescription": "Выдаляе перакадзіраваныя файлы, старэйшыя за адзін дзень.",
"TaskRefreshChannels": "Абнавіць каналы",
- "TaskDownloadMissingSubtitles": "Спампаваць адсутныя субтытры",
- "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных спісаў прайгравання HLS. Гэта задача можа працаваць у працягу доўгага часу.",
- "TaskRefreshTrickplayImages": "Стварыце выявы Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Стварае прагляд відэаролікаў для Trickplay у падключаных бібліятэках.",
- "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і спісы прайгравання",
- "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і спісаў прайгравання, якія больш не існуюць.",
- "TaskAudioNormalizationDescription": "Сканіруе файлы на прадмет нармалізацыі гуку.",
+ "TaskDownloadMissingSubtitles": "Спампаваць адсутныя субцітры",
+ "TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных плэй-лістоў HLS. Гэта задача можа працягнуцца шмат часу.",
+ "TaskRefreshTrickplayImages": "Стварыць выявы Trickplay",
+ "TaskRefreshTrickplayImagesDescription": "Стварае перадпрагляды відэаролікаў для Trickplay у падключаных бібліятэках.",
+ "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і плэй-лісты",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і плэй-лістоў, якія больш не існуюць.",
+ "TaskAudioNormalizationDescription": "Скануе файлы на прадмет нармалізацыі гуку.",
"TaskAudioNormalization": "Нармалізацыя гуку",
"TaskExtractMediaSegmentsDescription": "Выдае або атрымлівае медыясегменты з убудоў з падтрымкай MediaSegment.",
"TaskMoveTrickplayImagesDescription": "Перамяшчае існуючыя файлы trickplay у адпаведнасці з наладамі бібліятэкі.",
- "TaskDownloadMissingLyrics": "Спампаваць зніклыя тэксты песень",
- "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песень",
+ "TaskDownloadMissingLyrics": "Спампаваць адсутныя тэксты песняў",
+ "TaskDownloadMissingLyricsDescription": "Спампоўвае тэксты для песняў",
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
- "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay"
+ "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
+ "CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка",
+ "CleanupUserDataTaskDescription": "Ачысьціць усе дадзеныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
}
diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json
index 72f575753..fd3666ef1 100644
--- a/Emby.Server.Implementations/Localization/Core/bg-BG.json
+++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json
@@ -136,5 +136,7 @@
"TaskExtractMediaSegmentsDescription": "Изважда медиини сегменти от MediaSegment плъгини.",
"TaskMoveTrickplayImages": "Мигриране на Локацията за Trickplay изображения",
"TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.",
- "TaskExtractMediaSegments": "Сканиране за сегменти"
+ "TaskExtractMediaSegments": "Сканиране за сегменти",
+ "CleanupUserDataTask": "Задача за почистване на потребителски данни",
+ "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни."
}
diff --git a/Emby.Server.Implementations/Localization/Core/bn.json b/Emby.Server.Implementations/Localization/Core/bn.json
index 268a141ff..876773778 100644
--- a/Emby.Server.Implementations/Localization/Core/bn.json
+++ b/Emby.Server.Implementations/Localization/Core/bn.json
@@ -6,29 +6,29 @@
"Channels": "চ্যানেলসমূহ",
"CameraImageUploadedFrom": "{0} থেকে একটি নতুন ক্যামেরার চিত্র আপলোড করা হয়েছে",
"Books": "পুস্তকসমূহ",
- "AuthenticationSucceededWithUserName": "{0} অনুমোদন সফল",
+ "AuthenticationSucceededWithUserName": "{0} সফলভাবে অথেন্টিকেট করেছেন",
"Artists": "শিল্পীগণ",
"Application": "অ্যাপ্লিকেশন",
"Albums": "অ্যালবামসমূহ",
- "HeaderFavoriteEpisodes": "প্রিব পর্বগুলো",
+ "HeaderFavoriteEpisodes": "প্রিয় পর্বগুলো",
"HeaderFavoriteArtists": "প্রিয় শিল্পীরা",
"HeaderFavoriteAlbums": "প্রিয় এলবামগুলো",
"HeaderContinueWatching": "দেখতে থাকুন",
"HeaderAlbumArtists": "অ্যালবাম শিল্পীবৃন্দ",
- "Genres": "শৈলীধারাসমূহ",
+ "Genres": "ধরণ",
"Folders": "ফোল্ডারসমূহ",
"Favorites": "পছন্দসমূহ",
"FailedLoginAttemptWithUserName": "{0} লগিন করতে ব্যর্থ হয়েছে",
- "AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {0}",
+ "AppDeviceValues": "অ্যাপ: {0}, ডিভাইস: {1}",
"VersionNumber": "সংস্করণ {0}",
"ValueSpecialEpisodeName": "বিশেষ পর্ব - {0}",
"ValueHasBeenAddedToLibrary": "আপনার লাইব্রেরিতে {0} যোগ করা হয়েছে",
- "UserStoppedPlayingItemWithValues": "{2}তে {1} বাজানো শেষ করেছেন {0}",
- "UserStartedPlayingItemWithValues": "{2}তে {1} বাজাচ্ছেন {0}",
+ "UserStoppedPlayingItemWithValues": "{2}তে {1} প্লে শেষ করেছেন {0}",
+ "UserStartedPlayingItemWithValues": "{2}তে {1} প্লে করেছেন {0}",
"UserPolicyUpdatedWithName": "{0} এর জন্য ব্যবহার নীতি আপডেট করা হয়েছে",
"UserPasswordChangedWithName": "ব্যবহারকারী {0} এর পাসওয়ার্ড পরিবর্তিত হয়েছে",
- "UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন",
- "UserOfflineFromDevice": "{0} {1} থেকে বিযুক্ত হয়ে গেছে",
+ "UserOnlineFromDevice": "{0}, {1} থেকে অনলাইন আছে",
+ "UserOfflineFromDevice": "{0} {1} থেকে বিচ্ছিন্ন হয়ে গেছে",
"UserLockedOutWithName": "ব্যবহারকারী {0} ঢুকতে পারছে না",
"UserDownloadingItemWithValues": "{0}, {1} ডাউনলোড করছে",
"UserDeletedWithName": "ব্যবহারকারী {0}কে বাদ দেয়া হয়েছে",
@@ -36,28 +36,28 @@
"User": "ব্যবহারকারী",
"TvShows": "টিভি শোগুলো",
"System": "সিস্টেম",
- "Sync": "সমলয় স্থাপন",
- "SubtitleDownloadFailureFromForItem": "{2} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ",
+ "Sync": "সমন্বয় করুন",
+ "SubtitleDownloadFailureFromForItem": "{0} থেকে {1} এর জন্য সাবটাইটেল ডাউনলোড ব্যর্থ হয়েছে",
"StartupEmbyServerIsLoading": "জেলিফিন সার্ভার লোড হচ্ছে। দয়া করে একটু পরে আবার চেষ্টা করুন।",
- "Songs": "সঙ্গীতসমূহ",
- "Shows": "টিভি পর্ব",
+ "Songs": "সঙ্গীত সমূহ",
+ "Shows": "শো সমূহ",
"ServerNameNeedsToBeRestarted": "{0} রিস্টার্ট করা প্রয়োজন",
"ScheduledTaskStartedWithName": "{0} শুরু হয়েছে",
"ScheduledTaskFailedWithName": "{0} ব্যর্থ",
"ProviderValue": "প্রদানকারী: {0}",
"PluginUpdatedWithName": "{0} আপডেট করা হয়েছে",
- "PluginUninstalledWithName": "{0} বাদ দেয়া হয়েছে",
- "PluginInstalledWithName": "{0} ইন্সটল করা হয়েছে",
+ "PluginUninstalledWithName": "{0} আনইন্সটল হয়েছে",
+ "PluginInstalledWithName": "{0} ইন্সটল হয়েছে",
"Plugin": "প্লাগিন",
"Playlists": "প্লে লিস্ট সমূহ",
- "Photos": "চিত্রসমূহ",
- "NotificationOptionVideoPlaybackStopped": "ভিডিও চলা বন্ধ",
- "NotificationOptionVideoPlayback": "ভিডিও চলা শুরু হয়েছে",
- "NotificationOptionUserLockedOut": "ব্যবহারকারী ঢুকতে পারছে না",
+ "Photos": "ছবিসমূহ",
+ "NotificationOptionVideoPlaybackStopped": "ভিডিও প্লেব্যাক বন্ধ হয়েছে",
+ "NotificationOptionVideoPlayback": "ভিডিও প্লেব্যাক শুরু হয়েছে",
+ "NotificationOptionUserLockedOut": "ব্যবহারকারী লক আউট হয়েছে",
"NotificationOptionTaskFailed": "পরিকল্পিত কাজটি ব্যর্থ",
- "NotificationOptionServerRestartRequired": "সার্ভার রিস্টার্ট বাধ্যতামূলক",
- "NotificationOptionPluginUpdateInstalled": "প্লাগিন আপডেট ইন্সটল করা হয়েছে",
- "NotificationOptionPluginUninstalled": "প্লাগিন বাদ দেয়া হয়েছে",
+ "NotificationOptionServerRestartRequired": "সার্ভার রিস্টার্ট করা লাগবে",
+ "NotificationOptionPluginUpdateInstalled": "প্লাগিন আপডেট ইন্সটল হয়েছে",
+ "NotificationOptionPluginUninstalled": "প্লাগিন আনইনষ্টল হয়েছে",
"NotificationOptionPluginInstalled": "প্লাগিন ইন্সটল করা হয়েছে",
"NotificationOptionPluginError": "প্লাগিন ব্যর্থ",
"NotificationOptionNewLibraryContent": "নতুন কন্টেন্ট যোগ করা হয়েছে",
@@ -76,8 +76,8 @@
"Movies": "চলচ্চিত্রসমূহ",
"MixedContent": "মিশ্র কন্টেন্ট",
"MessageServerConfigurationUpdated": "সার্ভারের কনফিগারেশন আপডেট করা হয়েছে",
- "HeaderRecordingGroups": "রেকর্ডিং দল",
- "MessageNamedServerConfigurationUpdatedWithValue": "সার্ভারের {0} কনফিগারেসনের অংশ আপডেট করা হয়েছে",
+ "HeaderRecordingGroups": "রেকর্ডিং গ্রুপগুলো",
+ "MessageNamedServerConfigurationUpdatedWithValue": "সার্ভার কনফিগারেশন সেকশন {0} আপডেট করা হয়েছে",
"MessageApplicationUpdatedTo": "জেলিফিন সার্ভার {0} তে আপডেট করা হয়েছে",
"MessageApplicationUpdated": "জেলিফিন সার্ভার আপডেট করা হয়েছে",
"Latest": "সর্বশেষ",
@@ -85,51 +85,57 @@
"LabelIpAddressValue": "আইপি এড্রেস: {0}",
"ItemRemovedWithName": "{0} লাইব্রেরি থেকে বাদ দেয়া হয়েছে",
"ItemAddedWithName": "{0} লাইব্রেরিতে যোগ করা হয়েছে",
- "Inherit": "থেকে পাওয়া",
+ "Inherit": "উত্তরাধিকারসূত্র থেকে গ্রহণ করুন",
"HomeVideos": "হোম ভিডিও",
"HeaderNextUp": "এরপরে আসছে",
"HeaderLiveTV": "লাইভ টিভি",
"HeaderFavoriteSongs": "প্রিয় গানগুলো",
"HeaderFavoriteShows": "প্রিয় শোগুলো",
- "TasksLibraryCategory": "গ্রন্থাগার",
+ "TasksLibraryCategory": "লাইব্রেরি",
"TasksMaintenanceCategory": "রক্ষণাবেক্ষণ",
"TaskRefreshLibrary": "স্ক্যান মিডিয়া লাইব্রেরি",
- "TaskRefreshChapterImagesDescription": "অধ্যায়গুলিতে থাকা ভিডিওগুলির জন্য থাম্বনেইল তৈরি ।",
- "TaskRefreshChapterImages": "অধ্যায়ের চিত্রগুলি বের করুন",
- "TaskCleanCacheDescription": "সিস্টেমে আর প্রয়োজন নেই ক্যাশ, ফাইলগুলি মুছে ফেলুন।",
+ "TaskRefreshChapterImagesDescription": "যেসব ভিডিওতে চ্যাপ্টার রয়েছে, তাদের জন্য থাম্বনেইল তৈরি করবে।",
+ "TaskRefreshChapterImages": "চ্যাপ্টার ইমেজ বের করুন",
+ "TaskCleanCacheDescription": "সিস্টেমের অপ্রয়োজনীয় ক্যাশ ফাইলগুলো মুছে ফেলবে।",
"TaskCleanCache": "ক্লিন ক্যাশ ডিরেক্টরি",
"TasksChannelsCategory": "ইন্টারনেট চ্যানেল",
- "TasksApplicationCategory": "আবেদন",
+ "TasksApplicationCategory": "অ্যাপ্লিকেশন",
"TaskDownloadMissingSubtitlesDescription": "মেটাডেটা কনফিগারেশনের উপর ভিত্তি করে অনুপস্থিত সাবটাইটেলগুলির জন্য ইন্টারনেট অনুসন্ধান করে।",
"TaskDownloadMissingSubtitles": "অনুপস্থিত সাবটাইটেলগুলি ডাউনলোড করুন",
"TaskRefreshChannelsDescription": "ইন্টারনেট চ্যানেল তথ্য রিফ্রেশ করুন।",
"TaskRefreshChannels": "চ্যানেল রিফ্রেশ করুন",
- "TaskCleanTranscodeDescription": "এক দিনেরও বেশি পুরানো ট্রান্সকোড ফাইলগুলি মুছে ফেলুন।",
+ "TaskCleanTranscodeDescription": "এক দিনেরও বেশি পুরানো ট্রান্সকোড ফাইলগুলি মুছে ফেলবে।",
"TaskCleanTranscode": "ট্রান্সকোড ডিরেক্টরি ক্লিন করুন",
"TaskUpdatePluginsDescription": "স্বয়ংক্রিয়ভাবে আপডেট কনফিগার করা প্লাগইনগুলির জন্য আপডেট ডাউনলোড এবং ইনস্টল করুন।",
- "TaskUpdatePlugins": "প্লাগইন আপডেট করুন",
- "TaskRefreshPeopleDescription": "আপনার মিডিয়া লাইব্রেরিতে অভিনেতা এবং পরিচালকদের জন্য মেটাডাটা আপডেট করুন।",
- "TaskRefreshPeople": "পিপল রিফ্রেশ করুন",
- "TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলুন।",
- "TaskCleanLogs": "লগ ডিরেক্টরি ক্লিন করুন",
- "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করুন।",
+ "TaskUpdatePlugins": "আপডেট প্লাগইন",
+ "TaskRefreshPeopleDescription": "আপনার মিডিয়া লাইব্রেরিতে অভিনেতা এবং পরিচালকদের জন্য মেটাডাটা আপডেট করবে।",
+ "TaskRefreshPeople": "ব্যক্তিদের তথ্য রিফ্রেশ",
+ "TaskCleanLogsDescription": "{0} দিনের বেশী পুরানো লগ ফাইলগুলি মুছে ফেলবে।",
+ "TaskCleanLogs": "ক্লিন লগ ডিরেক্টরি",
+ "TaskRefreshLibraryDescription": "নতুন ফাইলের জন্য মিডিয়া লাইব্রেরি স্ক্যান এবং মেটাডাটা রিফ্রেশ করবে।",
"Undefined": "অসঙ্গায়িত",
"Forced": "জোরকরে",
- "TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের কাজের হিসাব মুছে দিন খালি করুন.",
- "TaskCleanActivityLog": "কাজের ফাইল খালি করুন",
+ "TaskCleanActivityLogDescription": "নির্ধারিত সময়ের আগের অ্যাক্টিভিটি লগ মুছে দিবে।",
+ "TaskCleanActivityLog": "অ্যাক্টিভিটি লগ মুছুন",
"Default": "ডিফল্ট",
- "HearingImpaired": "দুর্বল শ্রবণক্ষমতাধরদের জন্য",
+ "HearingImpaired": "শ্রবণ প্রতিবন্ধী",
"TaskOptimizeDatabaseDescription": "তথ্যভাণ্ডার সুবিন্যস্ত করে ও অব্যবহৃত জায়গা ছেড়ে দেয়। লাইব্রেরী স্ক্যান অথবা যেকোনো তথ্যভাণ্ডার পরিবর্তনের পর এই প্রক্রিয়া চালালে তথ্যভাণ্ডারের তথ্য প্রদান দ্রুততর হতে পারে।",
"External": "বাহ্যিক",
"TaskOptimizeDatabase": "তথ্যভাণ্ডার সুবিন্যাস",
"TaskKeyframeExtractor": "কি-ফ্রেম নিষ্কাশক",
"TaskKeyframeExtractorDescription": "ভিডিয়ো থেকে কি-ফ্রেম নিষ্কাশনের মাধ্যমে অধিকতর সঠিক HLS প্লে লিস্ট তৈরী করে। এই প্রক্রিয়া দীর্ঘ সময় ধরে চলতে পারে।",
- "TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি করুন",
+ "TaskRefreshTrickplayImages": "ট্রিকপ্লে ইমেজ তৈরি",
"TaskRefreshTrickplayImagesDescription": "সক্ষম লাইব্রেরিতে ভিডিওর জন্য ট্রিকপ্লে প্রিভিউ তৈরি করে।",
- "TaskDownloadMissingLyricsDescription": "গানের লিরিক্স ডাউনলোড করে",
- "TaskCleanCollectionsAndPlaylists": "সংগ্রহ এবং প্লেলিস্ট পরিষ্কার করুন",
- "TaskCleanCollectionsAndPlaylistsDescription": "সংগ্রহ এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।",
+ "TaskDownloadMissingLyricsDescription": "গানের জন্য লিরিকস ডাউনলোড করুন",
+ "TaskCleanCollectionsAndPlaylists": "কালেকশন এবং প্লেলিস্ট পরিষ্কার করুন",
+ "TaskCleanCollectionsAndPlaylistsDescription": "কালেকশন এবং প্লেলিস্ট থেকে আইটেমগুলি সরিয়ে দেয় যা আর বিদ্যমান নেই।",
"TaskExtractMediaSegments": "মিডিয়া সেগমেন্ট স্ক্যান",
- "TaskExtractMediaSegmentsDescription": "MediaSegment সক্ষম প্লাগইনগুলি থেকে মিডিয়া সেগমেন্টগুলি বের করে বা প্রাপ্ত করে।",
- "TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন"
+ "TaskExtractMediaSegmentsDescription": "মিডিয়া সেগমেন্ট সক্ষম প্লাগইনগুলি থেকে মিডিয়া সেগমেন্ট বের করে বা অর্জন করে।",
+ "TaskDownloadMissingLyrics": "অনুপস্থিত গান ডাউনলোড করুন",
+ "TaskMoveTrickplayImagesDescription": "লাইব্রেরির সেটিং অনুযায়ী বিদ্যমান ট্রিকপ্লে ফাইলগুলো সরিয়ে নেবে।",
+ "TaskAudioNormalizationDescription": "অডিও নর্মালাইজেশন তথ্যের জন্য ফাইল স্ক্যান করবে।",
+ "CleanupUserDataTaskDescription": "৯০ দিন বা তার বেশি সময় ধরে অনুপস্থিত মিডিয়া থেকে সকল ব্যবহারকারীর ডেটা (ওয়াচ স্টেট, ফেভারিট স্ট্যাটাস ইত্যাদি) মুছে ফেলবে।",
+ "TaskMoveTrickplayImages": "ট্রিকপ্লে ইমেজের অবস্থান পরিবর্তন",
+ "TaskAudioNormalization": "অডিও নর্মলাইজেশন",
+ "CleanupUserDataTask": "ইউজার ডেটা ক্লিনআপ কাজ"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json
index 6cce0e019..596df6348 100644
--- a/Emby.Server.Implementations/Localization/Core/ca.json
+++ b/Emby.Server.Implementations/Localization/Core/ca.json
@@ -13,10 +13,10 @@
"DeviceOnlineWithName": "{0} està connectat",
"FailedLoginAttemptWithUserName": "Intent de connexió fallit des de {0}",
"Favorites": "Preferits",
- "Folders": "Carpetes",
+ "Folders": "Directoris",
"Genres": "Gèneres",
"HeaderAlbumArtists": "Artistes de l'àlbum",
- "HeaderContinueWatching": "Continua veient",
+ "HeaderContinueWatching": "Continueu mirant",
"HeaderFavoriteAlbums": "Àlbums preferits",
"HeaderFavoriteArtists": "Artistes preferits",
"HeaderFavoriteEpisodes": "Episodis preferits",
@@ -24,11 +24,11 @@
"HeaderFavoriteSongs": "Cançons preferides",
"HeaderLiveTV": "TV en directe",
"HeaderNextUp": "A continuació",
- "HeaderRecordingGroups": "Grups Musicals",
+ "HeaderRecordingGroups": "Grups musicals",
"HomeVideos": "Vídeos domèstics",
"Inherit": "Heretat",
- "ItemAddedWithName": "{0} s'ha afegit a la biblioteca",
- "ItemRemovedWithName": "{0} s'ha eliminat de la biblioteca",
+ "ItemAddedWithName": "{0} s'ha afegit a la mediateca",
+ "ItemRemovedWithName": "{0} s'ha eliminat de la mediateca",
"LabelIpAddressValue": "Adreça IP: {0}",
"LabelRunningTimeValue": "Temps en marxa: {0}",
"Latest": "Darrers",
@@ -43,7 +43,7 @@
"NameInstallFailed": "{0} instal·lació fallida",
"NameSeasonNumber": "Temporada {0}",
"NameSeasonUnknown": "Temporada desconeguda",
- "NewVersionIsAvailable": "Una nova versió del servidor de Jellyfin està disponible per a descarregar.",
+ "NewVersionIsAvailable": "Hi ha disponible una versió nova del servidor de Jellyfin per a la descàrrega.",
"NotificationOptionApplicationUpdateAvailable": "Actualització de l'aplicatiu disponible",
"NotificationOptionApplicationUpdateInstalled": "Actualització de l'aplicatiu instal·lada",
"NotificationOptionAudioPlayback": "Reproducció d'àudio iniciada",
@@ -64,7 +64,7 @@
"Playlists": "Llistes de reproducció",
"Plugin": "Complement",
"PluginInstalledWithName": "{0} ha estat instal·lat",
- "PluginUninstalledWithName": "S'ha instalat {0}",
+ "PluginUninstalledWithName": "S'ha instal·lat {0}",
"PluginUpdatedWithName": "S'ha actualitzat {0}",
"ProviderValue": "Proveïdor: {0}",
"ScheduledTaskFailedWithName": "{0} ha fallat",
@@ -72,10 +72,10 @@
"ServerNameNeedsToBeRestarted": "S'ha de reiniciar {0}",
"Shows": "Sèries",
"Songs": "Cançons",
- "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu de nou en una estona.",
+ "StartupEmbyServerIsLoading": "El servidor de Jellyfin s'està carregant. Proveu-ho de nou en una estona.",
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "Els subtítols per a {1} no s'han pogut baixar de {0}",
- "Sync": "Sincronitzar",
+ "Sync": "Sincronitza",
"System": "Sistema",
"TvShows": "Sèries de TV",
"User": "Usuari",
@@ -89,52 +89,54 @@
"UserPolicyUpdatedWithName": "La política d'usuari s'ha actualitzat per a {0}",
"UserStartedPlayingItemWithValues": "{0} ha començat a reproduir {1} a {2}",
"UserStoppedPlayingItemWithValues": "{0} ha parat de reproduir {1} a {2}",
- "ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la teva biblioteca",
+ "ValueHasBeenAddedToLibrary": "S'ha afegit {0} a la mediateca",
"ValueSpecialEpisodeName": "Especial - {0}",
"VersionNumber": "Versió {0}",
"TaskDownloadMissingSubtitlesDescription": "Cerca a internet els subtítols que faltin a partir de la configuració de metadades.",
- "TaskDownloadMissingSubtitles": "Descarrega els subtítols que faltin",
+ "TaskDownloadMissingSubtitles": "Descàrrega dels subtítols que faltin",
"TaskRefreshChannelsDescription": "Actualitza la informació dels canals per internet.",
"TaskRefreshChannels": "Actualitza els canals",
- "TaskCleanTranscodeDescription": "Elimina els arxius de transcodificacions que tinguin més d'un dia.",
- "TaskCleanTranscode": "Neteja les transcodificacions",
- "TaskUpdatePluginsDescription": "Actualitza els complements que estan configurats per a actualitzar-se automàticament.",
- "TaskUpdatePlugins": "Actualitza els complements",
- "TaskRefreshPeopleDescription": "Actualitza les metadades dels actors i directors de la teva biblioteca de mitjans.",
- "TaskRefreshPeople": "Actualitza les persones",
- "TaskCleanLogsDescription": "Esborra els logs que tinguin més de {0} dies.",
- "TaskCleanLogs": "Neteja els registres",
- "TaskRefreshLibraryDescription": "Escaneja la biblioteca de mitjans buscant fitxers nous i refresca les metadades.",
- "TaskRefreshLibrary": "Escaneja la biblioteca de mitjans",
- "TaskRefreshChapterImagesDescription": "Crea les miniatures dels vídeos que tinguin capítols.",
- "TaskRefreshChapterImages": "Extreure les imatges dels capítols",
- "TaskCleanCacheDescription": "Elimina la memòria cau no necessària per al servidor.",
- "TaskCleanCache": "Elimina la memòria cau",
+ "TaskCleanTranscodeDescription": "Elimina els fitxers de transcodificacions que tinguin més d'un dia.",
+ "TaskCleanTranscode": "Neteja de les transcodificacions",
+ "TaskUpdatePluginsDescription": "Descarrega i instal·la els complements que estiguin configurats per a actualitzar-se automàticament.",
+ "TaskUpdatePlugins": "Actualització dels complements",
+ "TaskRefreshPeopleDescription": "Actualització de les metadades dels actors i directors de la mediateca.",
+ "TaskRefreshPeople": "Actualització de les persones",
+ "TaskCleanLogsDescription": "Esborra els registres que tinguin més de {0} dies.",
+ "TaskCleanLogs": "Neteja dels registres",
+ "TaskRefreshLibraryDescription": "Escaneja les mediateques, a la cerca de fitxers nous i refresca les metadades.",
+ "TaskRefreshLibrary": "Escaneig de les mediateques",
+ "TaskRefreshChapterImagesDescription": "Creació de les miniatures dels vídeos que tinguin capítols.",
+ "TaskRefreshChapterImages": "Extracció de les imatges dels capítols",
+ "TaskCleanCacheDescription": "Eliminació de la memòria cau no necessària per al servidor.",
+ "TaskCleanCache": "Eliminació de la memòria cau",
"TasksChannelsCategory": "Canals per internet",
"TasksApplicationCategory": "Aplicatiu",
- "TasksLibraryCategory": "Biblioteca",
+ "TasksLibraryCategory": "Mediateca",
"TasksMaintenanceCategory": "Manteniment",
- "TaskCleanActivityLogDescription": "Eliminades les entrades del registre d'activitats més antigues que l'antiguitat configurada.",
- "TaskCleanActivityLog": "Buidar el registre d'activitat",
+ "TaskCleanActivityLogDescription": "Eliminació de les entrades del registre d'activitats més antigues que l'antiguitat configurada.",
+ "TaskCleanActivityLog": "Buidatge del registre d'activitat",
"Undefined": "Indefinit",
"Forced": "Forçat",
"Default": "Per defecte",
- "TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la biblioteca o fer altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.",
- "TaskOptimizeDatabase": "Optimitzar la base de dades",
- "TaskKeyframeExtractorDescription": "Extreu fotogrames clau dels fitxers de vídeo per crear llistes de reproducció HLS més precises. Aquesta tasca pot durar molt de temps.",
- "TaskKeyframeExtractor": "Extractor de fotogrames clau",
+ "TaskOptimizeDatabaseDescription": "Compacta la base de dades i trunca l'espai lliure. Executar aquesta tasca després d’escanejar la mediateca o fer d'altres canvis que impliquin modificacions a la base de dades pot millorar el rendiment.",
+ "TaskOptimizeDatabase": "Optimització de la base de dades",
+ "TaskKeyframeExtractorDescription": "Extracció de fotogrames clau dels fitxers de vídeo per a crear llistes de reproducció HLS més precises. Aquesta tasca pot allargar-se molt en el temps.",
+ "TaskKeyframeExtractor": "Extracció de fotogrames clau",
"External": "Extern",
"HearingImpaired": "Discapacitat auditiva",
- "TaskRefreshTrickplayImages": "Generar miniatures de línia de temps",
- "TaskRefreshTrickplayImagesDescription": "Crear miniatures de línia de temps per vídeos en les biblioteques habilitades.",
+ "TaskRefreshTrickplayImages": "Generació d'imatges de previsualització",
+ "TaskRefreshTrickplayImagesDescription": "Creació d'imatges de previsualització per a vídeos en les mediateques habilitades.",
"TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.",
- "TaskCleanCollectionsAndPlaylists": "Neteja les col·leccions i llistes de reproducció",
- "TaskAudioNormalization": "Estabilització d'Àudio",
- "TaskAudioNormalizationDescription": "Escaneja arxius per dades d'estabilització d'àudio.",
- "TaskDownloadMissingLyricsDescription": "Baixar les lletres de les cançons",
- "TaskDownloadMissingLyrics": "Baixar les lletres que falten",
+ "TaskCleanCollectionsAndPlaylists": "Neteja de les col·leccions i llistes de reproducció",
+ "TaskAudioNormalization": "Estabilització de l'àudio",
+ "TaskAudioNormalizationDescription": "Escaneja els fitxer per a obtenir dades de normalització de l'àudio.",
+ "TaskDownloadMissingLyricsDescription": "Descàrrega de les lletres de les cançons",
+ "TaskDownloadMissingLyrics": "Descàrrega de les lletres que faltin",
"TaskExtractMediaSegments": "Escaneig de segments multimèdia",
"TaskExtractMediaSegmentsDescription": "Extreu o obté segments multimèdia usant els connectors MediaSegment activats.",
- "TaskMoveTrickplayImages": "Migra la ubicació de la imatge de Trickplay",
- "TaskMoveTrickplayImagesDescription": "Mou els fitxers trickplay existents segons la configuració de la biblioteca."
+ "TaskMoveTrickplayImages": "Migració de la ubicació de la imatge de previsualització",
+ "TaskMoveTrickplayImagesDescription": "Mou els fitxers existents d'imatges de previsualització segons la configuració de la mediateca.",
+ "CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.",
+ "CleanupUserDataTask": "Tasca de neteja de dades d'usuari"
}
diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json
index ba2e2700d..e14edcffa 100644
--- a/Emby.Server.Implementations/Localization/Core/cs.json
+++ b/Emby.Server.Implementations/Localization/Core/cs.json
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "Skenování segmentů médií",
"TaskExtractMediaSegmentsDescription": "Extrahuje či získá segmenty médií pomocí zásuvných modulů MediaSegment.",
"TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay",
- "TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny."
+ "TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny.",
+ "CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.",
+ "CleanupUserDataTask": "Pročistit uživatelská data"
}
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index d43d4097f..bbee38ba5 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "Scan for mediesegmenter",
"TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder",
"TaskMoveTrickplayImagesDescription": "Flyt eksisterende trickplay-billeder jævnfør biblioteksindstillinger.",
- "TaskExtractMediaSegmentsDescription": "Udtrækker eller henter mediesegmenter fra plugins som understøtter MediaSegment."
+ "TaskExtractMediaSegmentsDescription": "Udtrækker eller henter mediesegmenter fra plugins som understøtter MediaSegment.",
+ "CleanupUserDataTask": "Brugerdata oprydningsopgave",
+ "CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage."
}
diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json
index f5ae43bb5..e60d03e46 100644
--- a/Emby.Server.Implementations/Localization/Core/de.json
+++ b/Emby.Server.Implementations/Localization/Core/de.json
@@ -77,7 +77,7 @@
"SubtitleDownloadFailureFromForItem": "Untertitel von {0} für {1} konnten nicht heruntergeladen werden",
"Sync": "Synchronisation",
"System": "System",
- "TvShows": "TV-Serien",
+ "TvShows": "Serien",
"User": "Benutzer",
"UserCreatedWithName": "Benutzer {0} wurde erstellt",
"UserDeletedWithName": "Benutzer {0} wurde gelöscht",
@@ -90,7 +90,7 @@
"UserStartedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} gestartet",
"UserStoppedPlayingItemWithValues": "{0} hat die Wiedergabe von {1} auf {2} beendet",
"ValueHasBeenAddedToLibrary": "{0} wurde deiner Bibliothek hinzugefügt",
- "ValueSpecialEpisodeName": "Extra - {0}",
+ "ValueSpecialEpisodeName": "Extra – {0}",
"VersionNumber": "Version {0}",
"TaskDownloadMissingSubtitlesDescription": "Sucht im Internet basierend auf den Metadaten-Einstellungen nach fehlenden Untertiteln.",
"TaskDownloadMissingSubtitles": "Fehlende Untertitel herunterladen",
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "Mediensegmente scannen",
"TaskExtractMediaSegmentsDescription": "Extrahiert oder empfängt Mediensegmente von Plugins die Mediensegmente nutzen.",
"TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren",
- "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben."
+ "TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.",
+ "CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
+ "CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind."
}
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index f3195f0ea..2ba2085da 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImagesDescription": "Μετακινεί τα υπάρχοντα αρχεία trickplay σύμφωνα με τις ρυθμίσεις της βιβλιοθήκης.",
"TaskDownloadMissingLyricsDescription": "Κατεβάζει στίχους για τραγούδια",
"TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων",
- "TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment."
+ "TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
+ "CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
+ "CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη"
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index ca52ffb14..720f550b3 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "Media Segment Scan",
"TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.",
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
- "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings."
+ "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
+ "CleanupUserDataTask": "User data cleanup task",
+ "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days."
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json
index 9702ab712..c09d5af96 100644
--- a/Emby.Server.Implementations/Localization/Core/en-US.json
+++ b/Emby.Server.Implementations/Localization/Core/en-US.json
@@ -135,5 +135,7 @@
"TaskExtractMediaSegments": "Media Segment Scan",
"TaskExtractMediaSegmentsDescription": "Extracts or obtains media segments from MediaSegment enabled plugins.",
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
- "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings."
+ "TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
+ "CleanupUserDataTask": "User data cleanup task",
+ "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favorite status etc) from media that is no longer present for at least 90 days."
}
diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json
index cf31960f9..012f793a6 100644
--- a/Emby.Server.Implementations/Localization/Core/es-AR.json
+++ b/Emby.Server.Implementations/Localization/Core/es-AR.json
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "Escanear Segmentos de Media",
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medio de plugins habilitados para MediaSegment.",
"TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
- "TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay"
+ "TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay",
+ "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
+ "CleanupUserDataTask": "Tarea de limpieza de datos de usuarios"
}
diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json
index 5fb7e8ae1..2830c657b 100644
--- a/Emby.Server.Implementations/Localization/Core/es-MX.json
+++ b/Emby.Server.Implementations/Localization/Core/es-MX.json
@@ -30,7 +30,7 @@
"ItemAddedWithName": "{0} fue agregado a la biblioteca",
"ItemRemovedWithName": "{0} fue removido de la biblioteca",
"LabelIpAddressValue": "Dirección IP: {0}",
- "LabelRunningTimeValue": "Tiempo de reproducción: {0}",
+ "LabelRunningTimeValue": "Tiempo corriendo: {0}",
"Latest": "Recientes",
"MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado",
"MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}",
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.",
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
- "TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca."
+ "TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
+ "CleanupUserDataTask": "Tarea de limpieza de los datos del usuario",
+ "CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días."
}
diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json
index 661333d29..1ec5eaa2a 100644
--- a/Emby.Server.Implementations/Localization/Core/es.json
+++ b/Emby.Server.Implementations/Localization/Core/es.json
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.",
- "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay"
+ "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
+ "CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
+ "CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días."
}
diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json
index 2534f37c1..34c68c33b 100644
--- a/Emby.Server.Implementations/Localization/Core/es_419.json
+++ b/Emby.Server.Implementations/Localization/Core/es_419.json
@@ -135,5 +135,7 @@
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de complementos habilitados para MediaSegment.",
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
- "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay"
+ "TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
+ "CleanupUserDataTask": "Tarea de limpieza de datos de usuario",
+ "CleanupUserDataTaskDescription": "Limpia todos los datos de usuario (estado de visualización, favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días."
}
diff --git a/Emby.Server.Implementations/Localization/Core/es_DO.json b/Emby.Server.Implementations/Localization/Core/es_DO.json
index 8cdd06b7c..f98a5e5b2 100644
--- a/Emby.Server.Implementations/Localization/Core/es_DO.json
+++ b/Emby.Server.Implementations/Localization/Core/es_DO.json
@@ -125,5 +125,11 @@
"Undefined": "Sin definir",
"TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.",
"TaskCleanCacheDescription": "Elimina archivos caché que ya no son necesarios para el sistema.",
- "TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad."
+ "TaskCleanLogsDescription": "Elimina archivos de registro con más de {0} días de antigüedad.",
+ "NotificationOptionApplicationUpdateAvailable": "actualización disponible",
+ "TaskDownloadMissingLyrics": "Descargue letras desaparecidas",
+ "TaskDownloadMissingLyricsDescription": "Decarga letras para canciones",
+ "TaskMoveTrickplayImages": "Mover localización de foto vista previa",
+ "NotificationOptionApplicationUpdateInstalled": "Aplicación actualización disponible",
+ "CleanupUserDataTask": "Tarea de limpieza de los datos del usuario"
}
diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json
index 3b2bb70a9..2e692009b 100644
--- a/Emby.Server.Implementations/Localization/Core/et.json
+++ b/Emby.Server.Implementations/Localization/Core/et.json
@@ -1,14 +1,14 @@
{
"TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.",
- "UserDownloadingItemWithValues": "{0} laeb alla {1}",
+ "UserDownloadingItemWithValues": "{0} laadib alla {1}",
"HeaderRecordingGroups": "Salvestusrühmad",
"TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.",
"TaskOptimizeDatabase": "Optimeeri andmebaasi",
"TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.",
- "TaskDownloadMissingSubtitles": "Laadi alla puuduvad subtiitrid",
+ "TaskDownloadMissingSubtitles": "Hangi puuduvad subtiitrid",
"TaskRefreshChannelsDescription": "Värskendab veebikanalite teavet.",
"TaskRefreshChannels": "Värskenda kanaleid",
- "TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkodeerimisfailid.",
+ "TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkoodimisfailid.",
"TaskCleanTranscode": "Puhasta transkoodimise kataloog",
"TaskUpdatePluginsDescription": "Laadib alla ja paigaldab nende pluginate uuendused, mis on seadistatud automaatselt uuenduma.",
"TaskUpdatePlugins": "Uuenda pluginaid",
@@ -41,10 +41,10 @@
"StartupEmbyServerIsLoading": "Jellyfin server laadib. Proovi varsti uuesti.",
"User": "Kasutaja",
"Undefined": "Määratlemata",
- "TvShows": "Seriaalid",
+ "TvShows": "Sarjad",
"System": "Süsteem",
"Sync": "Sünkrooni",
- "Songs": "Laulud",
+ "Songs": "Lood",
"Shows": "Sarjad",
"ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada",
"ScheduledTaskFailedWithName": "{0} nurjus",
@@ -92,7 +92,7 @@
"HeaderNextUp": "Järgmisena",
"HeaderLiveTV": "Otse TV",
"HeaderFavoriteSongs": "Lemmiklood",
- "HeaderFavoriteShows": "Lemmikseriaalid",
+ "HeaderFavoriteShows": "Lemmiksarjad",
"HeaderFavoriteEpisodes": "Lemmikepisoodid",
"HeaderFavoriteArtists": "Lemmikesitajad",
"HeaderFavoriteAlbums": "Lemmikalbumid",
@@ -122,18 +122,20 @@
"UserOnlineFromDevice": "{0} on ühendatud seadmest {1}",
"External": "Väline",
"HearingImpaired": "Kuulmispuudega",
- "TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadreid, et luua täpsemaid HLS-i esitusloendeid. See ülesanne võib kesta pikka aega.",
- "TaskKeyframeExtractor": "Võtmekaadri ekstraktor",
- "TaskRefreshTrickplayImages": "Loo eelvaate pildid",
- "TaskRefreshTrickplayImagesDescription": "Loob eelvaated videotele, kus lubatud.",
- "TaskAudioNormalization": "Heli Normaliseerimine",
- "TaskAudioNormalizationDescription": "Skaneerib faile heli normaliseerimise andmete jaoks.",
- "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest asjad, mida enam ei eksisteeri.",
+ "TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadrid, et luua täpsemaid HLS-i esitusloendeid. See võib kesta pikka aega.",
+ "TaskKeyframeExtractor": "Eralda võtmekaadrid",
+ "TaskRefreshTrickplayImages": "Loo trickplay pildid",
+ "TaskRefreshTrickplayImagesDescription": "Loob trickplay eelvaated videotele lubatud meediakogudes.",
+ "TaskAudioNormalization": "Normaliseeri helitugevus",
+ "TaskAudioNormalizationDescription": "Otsib failidest helitugevuse normaliseerimise teavet.",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest üksused, mida enam ei eksisteeri.",
"TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid",
- "TaskDownloadMissingLyrics": "Lae alla puuduolev lüürika",
- "TaskDownloadMissingLyricsDescription": "Lae lauludele alla lüürika",
+ "TaskDownloadMissingLyrics": "Hangi puuduvad laulusõnad",
+ "TaskDownloadMissingLyricsDescription": "Laulusõnade allalaadimine",
"TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.",
- "TaskExtractMediaSegments": "Meediasegmentide skaneerimine",
+ "TaskExtractMediaSegments": "Skaneeri meediasegmente",
"TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.",
- "TaskMoveTrickplayImages": "Migreeri trickplay piltide asukoht"
+ "TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht",
+ "CleanupUserDataTask": "Puhasta kasutajaandmed",
+ "CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud."
}
diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json
index 4df4b90d3..c9a798cac 100644
--- a/Emby.Server.Implementations/Localization/Core/eu.json
+++ b/Emby.Server.Implementations/Localization/Core/eu.json
@@ -135,5 +135,7 @@
"TaskExtractMediaSegmentsDescription": "Media segmentuak atera edo lortzen ditu MediaSegment gaituta duten pluginetik.",
"TaskMoveTrickplayImages": "Aldatu Trickplay irudien kokalekua",
"TaskMoveTrickplayImagesDescription": "Lehendik dauden trickplay fitxategiak liburutegiaren ezarpenen arabera mugitzen dira.",
- "TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu."
+ "TaskAudioNormalizationDescription": "Audio normalizazio datuak lortzeko fitxategiak eskaneatzen ditu.",
+ "CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.",
+ "CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
index c9f580cd5..15a04d22c 100644
--- a/Emby.Server.Implementations/Localization/Core/fi.json
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -69,7 +69,7 @@
"TvShows": "Sarjat",
"Sync": "Synkronointi",
"SubtitleDownloadFailureFromForItem": "Tekstityksen lataus lähteestä \"{0}\" kohteelle \"{1}\" epäonnistui",
- "StartupEmbyServerIsLoading": "Jellyfin-palvelin latautuu. Yritä hetken kuluttua uudelleen.",
+ "StartupEmbyServerIsLoading": "Jellyfin-palvelin on latautumassa. Yritä hetken kuluttua uudelleen.",
"Songs": "Kappaleet",
"Shows": "Sarjat",
"ServerNameNeedsToBeRestarted": "\"{0}\" on käynnistettävä uudelleen",
@@ -79,7 +79,7 @@
"NotificationOptionVideoPlayback": "Videon toisto aloitettu",
"NotificationOptionUserLockedOut": "Käyttäjä on lukittu",
"NotificationOptionTaskFailed": "Ajoitettu tehtävä epäonnistui",
- "NotificationOptionServerRestartRequired": "Tarvitaan palvelimen uudelleenkäynnistys",
+ "NotificationOptionServerRestartRequired": "Palvelimen uudelleenkäynnistys vaaditaan",
"NotificationOptionPluginUpdateInstalled": "Lisäosa päivitettiin",
"NotificationOptionPluginUninstalled": "Lisäosa poistettiin",
"NotificationOptionPluginInstalled": "Lisäosa asennettiin",
@@ -135,5 +135,7 @@
"TaskDownloadMissingLyricsDescription": "Ladataan sanoituksia",
"TaskExtractMediaSegmentsDescription": "Poimii tai hankkii mediasegmenttejä MediaSegment-yhteensopivista laajennuksista.",
"TaskMoveTrickplayImages": "Siirrä Trickplay-kuvien sijainti",
- "TaskMoveTrickplayImagesDescription": "Siirtää olemassa olevia trickplay-tiedostoja kirjaston asetusten mukaan."
+ "TaskMoveTrickplayImagesDescription": "Siirtää olemassa olevia trickplay-tiedostoja kirjaston asetusten mukaan.",
+ "CleanupUserDataTask": "Käyttäjätietojen puhdistustehtävä",
+ "CleanupUserDataTaskDescription": "Puhdistaa kaikki käyttäjätiedot (katselutila, suosikit ym.) medioista, joita ei ole ollut saatavilla yli 90 päivään."
}
diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json
index a10912f01..6d079d2f5 100644
--- a/Emby.Server.Implementations/Localization/Core/fr-CA.json
+++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.",
"TaskDownloadMissingLyrics": "Télécharger les paroles des chansons manquantes",
"TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay",
- "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment."
+ "TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.",
+ "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
+ "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json
index c337d1932..8bf41c02a 100644
--- a/Emby.Server.Implementations/Localization/Core/fr.json
+++ b/Emby.Server.Implementations/Localization/Core/fr.json
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "Analyse des segments de média",
"TaskMoveTrickplayImages": "Changer l'emplacement des images Trickplay",
"TaskExtractMediaSegmentsDescription": "Extrait ou obtient des segments de média à partir des plugins compatibles avec MediaSegment.",
- "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque."
+ "TaskMoveTrickplayImagesDescription": "Déplace les fichiers trickplay existants en fonction des paramètres de la bibliothèque.",
+ "CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
+ "CleanupUserDataTask": "Tâche de nettoyage des données utilisateur"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json
index b8e787c20..8c0ae8922 100644
--- a/Emby.Server.Implementations/Localization/Core/ga.json
+++ b/Emby.Server.Implementations/Localization/Core/ga.json
@@ -135,5 +135,7 @@
"TaskUpdatePlugins": "Nuashonraigh Breiseáin",
"TaskCleanTranscodeDescription": "Scriostar comhaid traschódaithe níos mó ná lá amháin d'aois.",
"TaskCleanTranscode": "Eolaire Transcode Glan",
- "TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh"
+ "TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh",
+ "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."
}
diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json
index 3ba3e6679..b3f137feb 100644
--- a/Emby.Server.Implementations/Localization/Core/gl.json
+++ b/Emby.Server.Implementations/Localization/Core/gl.json
@@ -1,74 +1,74 @@
{
- "Albums": "Álbumes",
+ "Albums": "Álbums",
"Collections": "Coleccións",
"ChapterNameValue": "Capítulo {0}",
"Channels": "Canles",
- "CameraImageUploadedFrom": "Cargouse unha nova imaxe da cámara desde {0}",
+ "CameraImageUploadedFrom": "Cargouse unha nova imaxe de cámara dende {0}",
"Books": "Libros",
"AuthenticationSucceededWithUserName": "{0} autenticouse correctamente",
"Artists": "Artistas",
- "Application": "Aplicativo",
- "NotificationOptionServerRestartRequired": "Necesario un reinicio do servidor",
- "NotificationOptionPluginUpdateInstalled": "Actualización do Plugin instalada",
+ "Application": "Aplicación",
+ "NotificationOptionServerRestartRequired": "Necesario o reinicio do servidor",
+ "NotificationOptionPluginUpdateInstalled": "Actualización do plugin instalada",
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
"NotificationOptionPluginInstalled": "Plugin instalado",
- "NotificationOptionPluginError": "Fallo do Plugin",
+ "NotificationOptionPluginError": "Fallo do plugin",
"NotificationOptionNewLibraryContent": "Novo contido engadido",
"NotificationOptionInstallationFailed": "Fallo na instalación",
- "NotificationOptionCameraImageUploaded": "Imaxe da cámara subida",
- "NotificationOptionAudioPlaybackStopped": "Reproducción de audio parada",
+ "NotificationOptionCameraImageUploaded": "Imaxe da cámara cargada",
+ "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detida",
"NotificationOptionAudioPlayback": "Reproducción de audio comezada",
"NotificationOptionApplicationUpdateInstalled": "Actualización da aplicación instalada",
"NotificationOptionApplicationUpdateAvailable": "Actualización da aplicación dispoñible",
- "NewVersionIsAvailable": "Unha nova versión do Servidor Jellyfin está dispoñible para descarga.",
+ "NewVersionIsAvailable": "Nova versión do Servidor Jellyfin dispoñible para descargar.",
"NameSeasonUnknown": "Tempada descoñecida",
"NameSeasonNumber": "Tempada {0}",
"NameInstallFailed": "{0} instalación fallida",
- "MusicVideos": "Vídeos Musicais",
+ "MusicVideos": "Vídeos musicais",
"Music": "Música",
"Movies": "Películas",
- "MixedContent": "Contido Mixto",
- "MessageServerConfigurationUpdated": "A configuración do servidor foi actualizada",
- "MessageNamedServerConfigurationUpdatedWithValue": "A sección de configuración {0} do servidor foi actualizada",
- "MessageApplicationUpdatedTo": "O servidor Jellyfin foi actualizado a {0}",
- "MessageApplicationUpdated": "O servidor Jellyfin foi actualizado",
+ "MixedContent": "Contido mixto",
+ "MessageServerConfigurationUpdated": "Actualizouse a configuración do servidor",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Actualizouse a sección de configuración {0} do servidor",
+ "MessageApplicationUpdatedTo": "O servidor Jellyfin actualizouse a {0}",
+ "MessageApplicationUpdated": "O servidor Jellyfin actualizouse",
"Latest": "Último",
- "LabelRunningTimeValue": "Tempo de execución: {0}",
+ "LabelRunningTimeValue": "Tempo en execución: {0}",
"LabelIpAddressValue": "Enderezo IP: {0}",
- "ItemRemovedWithName": "{0} foi eliminado da biblioteca",
- "ItemAddedWithName": "{0} foi engadido a biblioteca",
+ "ItemRemovedWithName": "{0} eliminouse da biblioteca",
+ "ItemAddedWithName": "{0} engadiuse á biblioteca",
"Inherit": "Herdar",
"HomeVideos": "Videos caseiros",
- "HeaderRecordingGroups": "Grupos de Grabación",
+ "HeaderRecordingGroups": "Grupos de grabación",
"HeaderNextUp": "De seguido",
"HeaderLiveTV": "TV en directo",
- "HeaderFavoriteSongs": "Cancións Favoritas",
- "HeaderFavoriteShows": "Series de TV Favoritas",
- "HeaderFavoriteEpisodes": "Episodios Favoritos",
- "HeaderFavoriteArtists": "Artistas Favoritos",
- "HeaderFavoriteAlbums": "Álbunes Favoritos",
+ "HeaderFavoriteSongs": "Cancións favoritas",
+ "HeaderFavoriteShows": "Series de TV favoritas",
+ "HeaderFavoriteEpisodes": "Episodios favoritos",
+ "HeaderFavoriteArtists": "Artistas favoritos",
+ "HeaderFavoriteAlbums": "Álbums favoritos",
"HeaderContinueWatching": "Seguir vendo",
- "HeaderAlbumArtists": "Artistas do Album",
+ "HeaderAlbumArtists": "Artistas do álbum",
"Genres": "Xéneros",
"Forced": "Forzado",
"Folders": "Cartafoles",
"Favorites": "Favoritos",
- "FailedLoginAttemptWithUserName": "Intento de incio de sesión fallido {0}",
+ "FailedLoginAttemptWithUserName": "Fallo de intento de inicio de sesión dende {0}",
"DeviceOnlineWithName": "{0} conectouse",
"DeviceOfflineWithName": "{0} desconectouse",
"Default": "Por defecto",
"AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}",
- "TaskCleanLogs": "Limpar Carpeta de Rexistros",
- "TaskCleanActivityLog": "Limpar Rexistro de Actividade",
- "TasksChannelsCategory": "Canáis de Internet",
- "TaskUpdatePlugins": "Actualizar Plugins",
+ "TaskCleanLogs": "Limpar directorio de rexistros",
+ "TaskCleanActivityLog": "Limpar rexistro de actividade",
+ "TasksChannelsCategory": "Canles da Internet",
+ "TaskUpdatePlugins": "Actualizar plugins",
"User": "Usuario",
"Undefined": "Sen definir",
"TvShows": "Programas de TV",
"System": "Sistema",
"Sync": "Sincronizar",
"SubtitleDownloadFailureFromForItem": "Fallou a descarga de subtítulos para {1} dende {0}",
- "StartupEmbyServerIsLoading": "O Servidor Jellyfin está cargando. Por favor, reinténteo en breve.",
+ "StartupEmbyServerIsLoading": "O servidor Jellyfin está cargando. Por favor, ténteo axiña outra vez.",
"Songs": "Cancións",
"Shows": "Programas",
"ServerNameNeedsToBeRestarted": "{0} precisa ser reiniciado",
@@ -85,43 +85,57 @@
"UserDeletedWithName": "O usuario {0} foi borrado",
"UserCreatedWithName": "O usuario {0} foi creado",
"Plugin": "Plugin",
- "NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo parada",
+ "NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo detida",
"NotificationOptionVideoPlayback": "Reproducción de vídeo iniciada",
"NotificationOptionUserLockedOut": "Usuario bloqueado",
"NotificationOptionTaskFailed": "Falla na tarefa axendada",
- "TaskCleanTranscodeDescription": "Borra os arquivos de transcode anteriores a un día.",
- "TaskCleanTranscode": "Limpar Directorio de Transcode",
+ "TaskCleanTranscodeDescription": "Borra os ficheiros de transcodificación de hai más dun día.",
+ "TaskCleanTranscode": "Limpar o directorio de transcodificación",
"UserStoppedPlayingItemWithValues": "{0} rematou de reproducir {1} en {2}",
- "UserStartedPlayingItemWithValues": "{0} está reproducindo {1} en {2}",
- "TaskDownloadMissingSubtitlesDescription": "Busca en internet por subtítulos que faltan baseado na configuración de metadatos.",
+ "UserStartedPlayingItemWithValues": "{0} está a reproducir {1} en {2}",
+ "TaskDownloadMissingSubtitlesDescription": "Procura na internet os subtítulos que faltan segundo a configuración de metadatos.",
"TaskDownloadMissingSubtitles": "Descargar subtítulos que faltan",
- "TaskRefreshChannelsDescription": "Refresca a información do canle de internet.",
- "TaskRefreshChannels": "Refrescar Canles",
- "TaskUpdatePluginsDescription": "Descarga e instala actualizacións para plugins que están configurados para actualizarse automáticamente.",
- "TaskRefreshPeopleDescription": "Actualiza os metadatos dos actores e directores na túa libraría multimedia.",
- "TaskRefreshPeople": "Refrescar Persoas",
- "TaskCleanLogsDescription": "Borra arquivos de rexistro que son mais antigos que {0} días.",
- "TaskRefreshLibraryDescription": "Escanea a tua libraría multimedia buscando novos arquivos e refrescando os metadatos.",
- "TaskRefreshLibrary": "Escanear Libraría Multimedia",
- "TaskRefreshChapterImagesDescription": "Crea previsualizacións para videos que teñen capítulos.",
- "TaskRefreshChapterImages": "Extraer Imaxes dos Capítulos",
+ "TaskRefreshChannelsDescription": "Refresca a información da canle de internet.",
+ "TaskRefreshChannels": "Refrescar canles",
+ "TaskUpdatePluginsDescription": "Descarga e instala actualizacións dos plugins configurados para actualizarse automáticamente.",
+ "TaskRefreshPeopleDescription": "Actualiza os metadatos dos actores e directores na túa biblioteca de medios.",
+ "TaskRefreshPeople": "Refrescar persoas",
+ "TaskCleanLogsDescription": "Borra ficheiros de rexistro con máis de {0} días de antigüidade.",
+ "TaskRefreshLibraryDescription": "Escanea a túa biblioteca de medios á procura de novos ficheiros e refresca os metadatos.",
+ "TaskRefreshLibrary": "Escanear a biblioteca de medios",
+ "TaskRefreshChapterImagesDescription": "Crea miniaturas dos vídeos que teñen capítulos.",
+ "TaskRefreshChapterImages": "Extraer imaxes dos capítulos",
"TaskCleanCacheDescription": "Borra ficheiros da caché que xa non son necesarios para o sistema.",
- "TaskCleanCache": "Limpa Directorio de Caché",
- "TaskCleanActivityLogDescription": "Borra as entradas no rexistro de actividade anteriores á data configurada.",
+ "TaskCleanCache": "Limpar directorio de caché",
+ "TaskCleanActivityLogDescription": "Borra do rexistro de actividade as entradas anteriores á data configurada.",
"TasksApplicationCategory": "Aplicación",
"ValueSpecialEpisodeName": "Especial - {0}",
- "ValueHasBeenAddedToLibrary": "{0} foi engadido a túa libraría multimedia",
- "TasksLibraryCategory": "Libraría",
+ "ValueHasBeenAddedToLibrary": "{0} engadiuse á túa biblioteca de medios",
+ "TasksLibraryCategory": "Biblioteca",
"TasksMaintenanceCategory": "Mantemento",
"VersionNumber": "Versión {0}",
"UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}",
"UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}",
"UserOnlineFromDevice": "{0} está en liña desde {1}",
- "UserOfflineFromDevice": "{0} desconectouse desde {1}",
- "TaskOptimizeDatabaseDescription": "Compacta e libera o espazo libre da base de datos. Executar esta tarefa logo de realizar mudanzas que impliquen modificacións da base de datos ou despois de escanear a biblioteca pode traer mellorías de desempeño.",
+ "UserOfflineFromDevice": "{0} desconectouse dende {1}",
+ "TaskOptimizeDatabaseDescription": "Compacta e libera espazo na base de datos. Executar esta tarefa logo de facer cambios que muden a base de datos ou despois de escanear a biblioteca pode mellorar o rendemento.",
"TaskOptimizeDatabase": "Optimizar base de datos",
- "TaskKeyframeExtractorDescription": "Extrae fragmentos do vídeo para crear listas de reprodución HLS máis precisas. Podería levarlle bastante tempo.",
+ "TaskKeyframeExtractorDescription": "Extrae fotogramas clave dos vídeos para crear listas de reprodución HLS máis precisas. Podería levar moito tempo.",
"External": "Externo",
"HearingImpaired": "Problemas de audición",
- "TaskKeyframeExtractor": "Extractor de fragmentos"
+ "TaskKeyframeExtractor": "Extractor de fotogramas clave",
+ "TaskAudioNormalization": "Normalización de volume",
+ "TaskRefreshTrickplayImagesDescription": "Crea miniaturas de previsualización para os vídeos nas bibliotecas habilitadas.",
+ "TaskDownloadMissingLyrics": "Descargar letras que faltan",
+ "TaskDownloadMissingLyricsDescription": "Descarga as letras das cancións",
+ "TaskCleanCollectionsAndPlaylists": "Limpar coleccións e listas de reprodución",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Quita ítems que xa non existen das coleccións e listas de reprodución.",
+ "TaskExtractMediaSegmentsDescription": "Procura segmentos de medios cos plugins habilitados.",
+ "TaskExtractMediaSegments": "Escaneo de segmentos de medios",
+ "TaskMoveTrickplayImages": "Migrar as miniaturas de previsualización a outra ubicación",
+ "TaskMoveTrickplayImagesDescription": "Move as miniaturas de previsualización segundo a configuración da biblioteca.",
+ "TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
+ "TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
+ "CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
+ "CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
}
diff --git a/Emby.Server.Implementations/Localization/Core/gsw.json b/Emby.Server.Implementations/Localization/Core/gsw.json
index b95d07d5c..e1ee8cf7c 100644
--- a/Emby.Server.Implementations/Localization/Core/gsw.json
+++ b/Emby.Server.Implementations/Localization/Core/gsw.json
@@ -11,11 +11,11 @@
"Collections": "Sammlungen",
"DeviceOfflineWithName": "{0} wurde getrennt",
"DeviceOnlineWithName": "{0} ist verbunden",
- "FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
- "Favorites": "Favoriten",
+ "FailedLoginAttemptWithUserName": "Fählgschlagene Ameldeversuech vo {0}",
+ "Favorites": "Favorite",
"Folders": "Ordner",
"Genres": "Genre",
- "HeaderAlbumArtists": "Album-Künstler",
+ "HeaderAlbumArtists": "Album-Künschtler",
"HeaderContinueWatching": "weiter schauen",
"HeaderFavoriteAlbums": "Lieblingsalben",
"HeaderFavoriteArtists": "Lieblings-Künstler",
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index 34d5cf050..90c921898 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -32,8 +32,8 @@
"LabelIpAddressValue": "Ip כתובת: {0}",
"LabelRunningTimeValue": "משך צפייה: {0}",
"Latest": "אחרון",
- "MessageApplicationUpdated": "שרת ג'ליפין עודכן",
- "MessageApplicationUpdatedTo": "שרת ג'ליפין עודכן לגרסה {0}",
+ "MessageApplicationUpdated": "שרת Jellyfin עודכן",
+ "MessageApplicationUpdatedTo": "שרת Jellyfin עודכן לגרסה {0}",
"MessageNamedServerConfigurationUpdatedWithValue": "סעיף הגדרת השרת {0} עודכן",
"MessageServerConfigurationUpdated": "תצורת השרת עודכנה",
"MixedContent": "תוכן מעורב",
@@ -43,7 +43,7 @@
"NameInstallFailed": "התקנת {0} נכשלה",
"NameSeasonNumber": "עונה {0}",
"NameSeasonUnknown": "עונה לא ידועה",
- "NewVersionIsAvailable": "גרסה חדשה של שרת ג'ליפין זמינה להורדה.",
+ "NewVersionIsAvailable": "גרסה חדשה של שרת Jellyfin זמינה להורדה.",
"NotificationOptionApplicationUpdateAvailable": "קיים עדכון זמין ליישום",
"NotificationOptionApplicationUpdateInstalled": "עדכון ליישום הותקן",
"NotificationOptionAudioPlayback": "ניגון שמע החל",
@@ -72,7 +72,7 @@
"ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש",
"Shows": "סדרות",
"Songs": "שירים",
- "StartupEmbyServerIsLoading": "שרת ג'ליפין טוען. נא לנסות שוב בקרוב.",
+ "StartupEmbyServerIsLoading": "שרת Jellyfin בתהליך טעינה. נא לנסות שוב בקרוב.",
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה",
"Sync": "סנכרון",
@@ -100,14 +100,14 @@
"TasksLibraryCategory": "ספרייה",
"TasksMaintenanceCategory": "תחזוקה",
"TaskUpdatePlugins": "עדכן תוספים",
- "TaskRefreshPeopleDescription": "מעדכן מטא נתונים עבור שחקנים ובמאים בספריית המדיה שלך.",
+ "TaskRefreshPeopleDescription": "מעדכן מטא-דאטה עבור שחקנים ובמאים בספריית המדיה שלך.",
"TaskRefreshPeople": "רענן אנשים",
"TaskCleanLogsDescription": "מוחק קבצי יומן בני יותר מ- {0} ימים.",
"TaskCleanLogs": "ניקוי תיקיית יומן",
- "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא נתונים.",
+ "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא-דאטה.",
"TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות לסרטונים שיש להם פרקים.",
"TasksChannelsCategory": "ערוצי אינטרנט",
- "TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט עבור הכתוביות החסרות בהתבסס על המטה-דיאטה.",
+ "TaskDownloadMissingSubtitlesDescription": "חפש באינטרנט כתוביות חסרות בהתבסס על המטא-דאטה.",
"TaskDownloadMissingSubtitles": "הורד כתוביות חסרות",
"TaskRefreshChannelsDescription": "רענן פרטי ערוץ אינטרנטי.",
"TaskRefreshChannels": "רענן ערוץ",
@@ -125,16 +125,18 @@
"TaskKeyframeExtractor": "מחלץ תמונות מפתח",
"External": "חיצוני",
"HearingImpaired": "לקוי שמיעה",
- "TaskRefreshTrickplayImages": "יצירת תמונות המחשה",
- "TaskRefreshTrickplayImagesDescription": "יוצר תמונות המחשה לסרטונים שפעילים בספריות.",
+ "TaskRefreshTrickplayImages": "יצירת תמונות Trickplay",
+ "TaskRefreshTrickplayImagesDescription": "יוצר תמונות Trickplay לסרטונים בספריות הפעילות.",
"TaskAudioNormalization": "נרמול שמע",
"TaskCleanCollectionsAndPlaylistsDescription": "מנקה פריטים לא קיימים מאוספים ורשימות השמעה.",
"TaskAudioNormalizationDescription": "מחפש קבצי נורמליזציה של שמע.",
"TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה",
"TaskDownloadMissingLyrics": "הורדת מילים חסרות",
"TaskDownloadMissingLyricsDescription": "הורדת מילים לשירים",
- "TaskMoveTrickplayImages": "העברת מיקום התמונות",
+ "TaskMoveTrickplayImages": "העברת מיקום של תמונות Trickplay",
"TaskExtractMediaSegments": "סריקת מדיה",
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
- "TaskMoveTrickplayImagesDescription": "הזזת קבצי טריקפליי קיימים בהתאם להגדרות הספרייה."
+ "TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
+ "CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
+ "CleanupUserDataTask": "משימת ניקוי מידע משתמש"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he_IL.json b/Emby.Server.Implementations/Localization/Core/he_IL.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/he_IL.json
@@ -0,0 +1 @@
+{}
diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json
index 813b18ad4..80db975cc 100644
--- a/Emby.Server.Implementations/Localization/Core/hi.json
+++ b/Emby.Server.Implementations/Localization/Core/hi.json
@@ -129,5 +129,12 @@
"TaskAudioNormalization": "श्रव्य सामान्यीकरण",
"TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें",
"TaskDownloadMissingLyrics": "लापता गानों के बोल डाउनलोड करेँ",
- "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है"
+ "TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है",
+ "TaskExtractMediaSegments": "मीडिया सेगमेंट स्कैन",
+ "TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।",
+ "TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
+ "TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
+ "TaskCleanCollectionsAndPlaylistsDescription": "संग्रहों और प्लेलिस्टों से उन आइटमों को हटाता है जो अब मौजूद नहीं हैं।",
+ "TaskCleanCollectionsAndPlaylists": "संग्रह और प्लेलिस्ट साफ़ करें",
+ "CleanupUserDataTask": "यूज़र डेटा की सफाई करता है।"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index d5ab7fa09..67263d3b2 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -125,8 +125,8 @@
"TaskKeyframeExtractor": "Izvoditelj ključnog okvira",
"TaskOptimizeDatabaseDescription": "Sažima bazu podataka i uklanja prazan prostor. Pokretanje ovog zadatka, može poboljšati performanse nakon provođenja indeksiranja biblioteke ili provođenja drugih promjena koje utječu na bazu podataka.",
"HearingImpaired": "Oštećen sluh",
- "TaskRefreshTrickplayImages": "Generiraj Trickplay Slike",
- "TaskRefreshTrickplayImagesDescription": "Kreira trickplay pretpreglede za videe u omogućenim knjižnicama.",
+ "TaskRefreshTrickplayImages": "Generiraj slike brzog pregledavanja",
+ "TaskRefreshTrickplayImagesDescription": "Stvara preglede brzog pregledavanja za videa u aktiviranim bibliotekama.",
"TaskAudioNormalization": "Normalizacija zvuka",
"TaskAudioNormalizationDescription": "Skenira datoteke u potrazi za podacima o normalizaciji zvuka.",
"TaskCleanCollectionsAndPlaylistsDescription": "Uklanja stavke iz zbirki i popisa za reprodukciju koje više ne postoje.",
@@ -135,6 +135,8 @@
"TaskDownloadMissingLyrics": "Preuzmi tekstove koji nedostaju",
"TaskDownloadMissingLyricsDescription": "Preuzmi tekstove pjesama",
"TaskExtractMediaSegmentsDescription": "Izvlači ili pribavlja dijelove medija iz omogućenih media pluginova.",
- "TaskMoveTrickplayImages": "Preseli lokaciju Trickplay slika",
- "TaskMoveTrickplayImagesDescription": "Preseli lokaciju Trickplay slika prema postavkama zbirke."
+ "TaskMoveTrickplayImages": "Premjesti mjesto slika brzog pregledavanja",
+ "TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja prema postavkama biblioteke.",
+ "CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
+ "CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ht.json b/Emby.Server.Implementations/Localization/Core/ht.json
index 4fcba99e9..f927d3173 100644
--- a/Emby.Server.Implementations/Localization/Core/ht.json
+++ b/Emby.Server.Implementations/Localization/Core/ht.json
@@ -1,3 +1,62 @@
{
- "Books": "liv"
+ "Books": "Liv",
+ "TasksLibraryCategory": "Libreri",
+ "Albums": "Albòm yo",
+ "Artists": "Atis yo",
+ "Application": "Aplikasyon",
+ "Channels": "Kanal yo",
+ "ChapterNameValue": "Chapit {0}",
+ "Default": "Defo",
+ "DeviceOnlineWithName": "{0} konekte",
+ "DeviceOfflineWithName": "{0} dekonekte",
+ "External": "Extèn",
+ "Collections": "Koleksyon yo",
+ "Favorites": "Pi Renmen",
+ "Folders": "Dosye",
+ "Genres": "Jan yo",
+ "Forced": "Fòse",
+ "HeaderAlbumArtists": "Albòm Atis",
+ "HeaderContinueWatching": "Kontinye Kade",
+ "HeaderFavoriteAlbums": "Albòm Pi Renmen",
+ "HeaderFavoriteArtists": "Atis Pi Renmen",
+ "HeaderFavoriteEpisodes": "Epizòd Pi Renmen",
+ "HeaderFavoriteShows": "Emisyon Pi Renmen",
+ "HeaderFavoriteSongs": "Mizik Pi Renmen",
+ "HeaderLiveTV": "Televizyon an Direk",
+ "HeaderNextUp": "Pwochen an",
+ "HomeVideos": "Videyo Lakay",
+ "Latest": "Pi Resan",
+ "MessageApplicationUpdated": "Sèvè Jellyfin met a jou",
+ "MessageApplicationUpdatedTo": "Sèvè Jellyfin met a jou sou {0}",
+ "Movies": "Fim",
+ "MixedContent": "Kontni Melanje",
+ "Music": "Mizik",
+ "MusicVideos": "Videyo Mizik",
+ "NameInstallFailed": "{0} enstalasyon fe fayit",
+ "NameSeasonNumber": "Sezon {0}",
+ "NameSeasonUnknown": "Sezon Enkoni",
+ "NotificationOptionCameraImageUploaded": "Imaj Kamera telechaje",
+ "NotificationOptionInstallationFailed": "Enstalasyon echwe",
+ "Photos": "Foto",
+ "PluginInstalledWithName": "{0} te enstale",
+ "PluginUninstalledWithName": "{0} te dezenstale",
+ "PluginUpdatedWithName": "{0} te mi a jou",
+ "ScheduledTaskFailedWithName": "{0} echwe",
+ "ScheduledTaskStartedWithName": "{0} komanse",
+ "Songs": "Mizik yo",
+ "Shows": "Emisyon yo",
+ "System": "Sistèm",
+ "TvShows": "Emisyon Tele",
+ "User": "Itilizatè",
+ "UserCreatedWithName": "Itilizatè {0} kreye",
+ "UserDeletedWithName": "Itilizatè {0} a efase",
+ "UserDownloadingItemWithValues": "{0} ap telechaje {1}",
+ "UserOfflineFromDevice": "{0} dekonekte de {1}",
+ "UserStartedPlayingItemWithValues": "{0} ap jwe {1} sou {2}",
+ "UserStoppedPlayingItemWithValues": "{0} fin jwe {1} sou {2}",
+ "UserPasswordChangedWithName": "Modpas la chanje pou Itilizatè {0}",
+ "ValueSpecialEpisodeName": "Spesyal - {0}",
+ "VersionNumber": "Vesyon {0}",
+ "TasksApplicationCategory": "Aplikasyon",
+ "TasksMaintenanceCategory": "Antretyen"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
index 1a9c3ee8b..81a996330 100644
--- a/Emby.Server.Implementations/Localization/Core/hu.json
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -1,6 +1,6 @@
{
"Albums": "Albumok",
- "AppDeviceValues": "Program: {0}, eszköz: {1}",
+ "AppDeviceValues": "Program: {0}, Eszköz: {1}",
"Application": "Alkalmazás",
"Artists": "Előadók",
"AuthenticationSucceededWithUserName": "{0} sikeresen hitelesítve",
@@ -136,5 +136,7 @@
"TaskDownloadMissingLyricsDescription": "Zenék szövegének letöltése",
"TaskMoveTrickplayImages": "Trickplay képek helyének átköltöztetése",
"TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.",
- "TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből."
+ "TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből.",
+ "CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.",
+ "CleanupUserDataTask": "Felhasználói adatok tisztítása feladat"
}
diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json
index b925a482b..2a4281685 100644
--- a/Emby.Server.Implementations/Localization/Core/id.json
+++ b/Emby.Server.Implementations/Localization/Core/id.json
@@ -129,5 +129,13 @@
"TaskAudioNormalizationDescription": "Pindai file untuk data normalisasi audio.",
"TaskAudioNormalization": "Normalisasi Audio",
"TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan daftar putar",
- "TaskCleanCollectionsAndPlaylistsDescription": "Menghapus item dari koleksi dan daftar putar yang sudah tidak ada."
+ "TaskCleanCollectionsAndPlaylistsDescription": "Menghapus item dari koleksi dan daftar putar yang sudah tidak ada.",
+ "TaskDownloadMissingLyricsDescription": "Unduh lirik untuk lagu",
+ "TaskExtractMediaSegmentsDescription": "Mengekstrak atau memperoleh segmen media dari plugin yang mendukung MediaSegment.",
+ "TaskMoveTrickplayImagesDescription": "Memindahkan file trickplay yang sudah ada sesuai dengan pengaturan pustaka.",
+ "CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (status tontonan, status favorit, dll.) dari media yang sudah tidak ada selama setidaknya 90 hari.",
+ "TaskExtractMediaSegments": "Scan Segmen media",
+ "TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay",
+ "TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang",
+ "CleanupUserDataTask": "Tugas Pembersihan Data Pengguna"
}
diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json
index 672c686fa..6f94df9d7 100644
--- a/Emby.Server.Implementations/Localization/Core/is.json
+++ b/Emby.Server.Implementations/Localization/Core/is.json
@@ -131,5 +131,8 @@
"TaskCleanCollectionsAndPlaylists": "Hreinsa söfn og spilunarlista",
"TaskCleanCollectionsAndPlaylistsDescription": "Fjarlægir hluti úr söfnum og spilalistum sem eru ekki lengur til.",
"TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög",
- "TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar"
+ "TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar",
+ "TaskExtractMediaSegments": "Skönnun efnishluta",
+ "CleanupUserDataTask": "Hreinsun notendagagna",
+ "CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga."
}
diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json
index e05afbabe..421c4ee30 100644
--- a/Emby.Server.Implementations/Localization/Core/it.json
+++ b/Emby.Server.Implementations/Localization/Core/it.json
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImages": "Sposta le immagini Trickplay",
"TaskMoveTrickplayImagesDescription": "Sposta le immagini Trickplay esistenti secondo la configurazione della libreria.",
"TaskExtractMediaSegmentsDescription": "Estrae o ottiene segmenti multimediali dai plugin abilitati MediaSegment.",
- "TaskExtractMediaSegments": "Scansiona Segmento Media"
+ "TaskExtractMediaSegments": "Scansiona Segmento Media",
+ "CleanupUserDataTask": "Task di pulizia dei dati utente",
+ "CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index 14a576592..d564d54ce 100644
--- a/Emby.Server.Implementations/Localization/Core/ja.json
+++ b/Emby.Server.Implementations/Localization/Core/ja.json
@@ -135,5 +135,7 @@
"TaskMoveTrickplayImages": "Trickplayの画像を移動",
"TaskMoveTrickplayImagesDescription": "ライブラリ設定によりTrickplayのファイルを移動。",
"TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
- "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。"
+ "TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
+ "CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
+ "CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。"
}
diff --git a/Emby.Server.Implementations/Localization/Core/kn.json b/Emby.Server.Implementations/Localization/Core/kn.json
index 5e2b3756b..9f49be53b 100644
--- a/Emby.Server.Implementations/Localization/Core/kn.json
+++ b/Emby.Server.Implementations/Localization/Core/kn.json
@@ -25,7 +25,7 @@
"DeviceOfflineWithName": "{0} ಸಂಪರ್ಕ ಕಡಿತಗೊಂಡಿದೆ",
"DeviceOnlineWithName": "{0} ಸಂಪರ್ಕಗೊಂಡಿದೆ",
"External": "ಹೊರಗಿನ",
- "FailedLoginAttemptWithUserName": "{0} ರಿಂದ ವಿಫಲ ಲಾಗಿನ್ ಪ್ರಯತ್ನ",
+ "FailedLoginAttemptWithUserName": "ವಿಫಲ ಲಾಗಿನ್ ಪ್ರಯತ್ನ ಸಂಖ್ಯೆ {0}",
"Favorites": "ಮೆಚ್ಚಿನವುಗಳು",
"Folders": "ಫೋಲ್ಡರ್‌ಗಳು",
"Forced": "ಬಲವಂತವಾಗಿ",
@@ -123,5 +123,13 @@
"TaskUpdatePlugins": "ಪ್ಲಗಿನ್‌ಗಳನ್ನು ನವೀಕರಿಸಿ",
"TaskCleanTranscode": "ಟ್ರಾನ್ಸ್‌ಕೋಡ್ ಡೈರೆಕ್ಟರಿಯನ್ನು ಸ್ವಚ್ಛಗೊಳಿಸಿ",
"TaskRefreshChannels": "ಚಾನಲ್‌ಗಳನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡಿ",
- "TaskRefreshChannelsDescription": "ಇಂಟರ್ನೆಟ್ ಚಾನಲ್ ಮಾಹಿತಿಯನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡುತ್ತದೆ."
+ "TaskRefreshChannelsDescription": "ಇಂಟರ್ನೆಟ್ ಚಾನಲ್ ಮಾಹಿತಿಯನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡುತ್ತದೆ.",
+ "TaskAudioNormalizationDescription": "ಧ್ವನಿ ಸಾಮಾನ್ಯೀಕರಣ ಮಾಹಿತಿಗಾಗಿ ಕಡತ‌ಗಳನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡುತ್ತದೆ.",
+ "TaskDownloadMissingLyricsDescription": "ಹಾಡುಗಳಿಗೆ ಸಾಹಿತ್ಯ ಪಡೆಯಿರಿ",
+ "TaskExtractMediaSegments": "ಮಾಧ್ಯಮ ವಿಭಾಗದ ಹುಡುಕು",
+ "TaskDownloadMissingLyrics": "ಇಲ್ಲದ ಸಾಹಿತ್ಯವನ್ನು ಪಡೆಯಿರಿ",
+ "TaskAudioNormalization": "ಧ್ವನಿ ಸಾಮಾನ್ಯೀಕರಣ",
+ "TaskRefreshTrickplayImages": "ಟ್ರಿಕ್‌ಪ್ಲೇ ಚಿತ್ರಗಳನ್ನು ರಚಿಸಿ",
+ "TaskCleanCollectionsAndPlaylists": "ಸಂಗ್ರಹಗಳು ಮತ್ತು ಪ್ಲೇಪಟ್ಟಿಗಳನ್ನು ಸ್ವಚ್ಛಗೊಳಿಸಿ",
+ "TaskCleanCollectionsAndPlaylistsDescription": "ಇಲ್ಲದ ಸಂಗ್ರಹಗಳು ಮತ್ತು ಪ್ಲೇಪಟ್ಟಿಗಳಿಂದ ವಸ್ತುಗಳನ್ನು ತೆಗೆದುಹಾಕುತ್ತದೆ."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json
index efc9f61dd..3d1b1ed27 100644
--- a/Emby.Server.Implementations/Localization/Core/ko.json
+++ b/Emby.Server.Implementations/Localization/Core/ko.json
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImages": "트릭플레이 이미지 위치 마이그레이션",
"TaskMoveTrickplayImagesDescription": "추출된 트릭플레이 이미지를 라이브러리 설정에 따라 이동합니다.",
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
- "TaskDownloadMissingLyricsDescription": "가사 다운로드"
+ "TaskDownloadMissingLyricsDescription": "가사 다운로드",
+ "CleanupUserDataTask": "사용자 데이터 정리 작업",
+ "CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다."
}
diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json
index 46fc49f5e..3918ab81c 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -8,7 +8,7 @@
"CameraImageUploadedFrom": "Nauja nuotrauka įkelta iš kameros {0}",
"Channels": "Kanalai",
"ChapterNameValue": "Scena{0}",
- "Collections": "Kolekcijos",
+ "Collections": "Rinkiniai",
"DeviceOfflineWithName": "{0} buvo atjungtas",
"DeviceOnlineWithName": "{0} prisijungęs",
"FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti",
@@ -17,18 +17,18 @@
"Genres": "Žanrai",
"HeaderAlbumArtists": "Albumo atlikėjai",
"HeaderContinueWatching": "Žiūrėti toliau",
- "HeaderFavoriteAlbums": "Mėgstami Albumai",
- "HeaderFavoriteArtists": "Mėgstami Atlikėjai",
+ "HeaderFavoriteAlbums": "Mėgstami albumai",
+ "HeaderFavoriteArtists": "Mėgstami atlikėjai",
"HeaderFavoriteEpisodes": "Mėgstamiausios serijos",
"HeaderFavoriteShows": "Mėgstamiausios TV Laidos",
"HeaderFavoriteSongs": "Mėgstamos Dainos",
"HeaderLiveTV": "Tiesioginė TV",
- "HeaderNextUp": "Toliau eilėje",
+ "HeaderNextUp": "Toliau",
"HeaderRecordingGroups": "Įrašų grupės",
"HomeVideos": "Namų vaizdo įrašai",
"Inherit": "Paveldėti",
- "ItemAddedWithName": "{0} - buvo įkeltas į mediateką",
- "ItemRemovedWithName": "{0} - buvo pašalinta iš mediatekos",
+ "ItemAddedWithName": "{0} - buvo įkeltas į biblioteką",
+ "ItemRemovedWithName": "{0} - buvo pašalinta iš bibliotekos",
"LabelIpAddressValue": "IP adresas: {0}",
"LabelRunningTimeValue": "Trukmė: {0}",
"Latest": "Naujausi",
@@ -36,7 +36,7 @@
"MessageApplicationUpdatedTo": "\"Jellyfin Server\" buvo atnaujinta iki {0}",
"MessageNamedServerConfigurationUpdatedWithValue": "Serverio nustatymai (skyrius {0}) buvo atnaujinti",
"MessageServerConfigurationUpdated": "Serverio nustatymai buvo atnaujinti",
- "MixedContent": "Mixed content",
+ "MixedContent": "Mišrus turinys",
"Movies": "Filmai",
"Music": "Muzika",
"MusicVideos": "Muzikiniai vaizdo įrašai",
@@ -53,21 +53,21 @@
"NotificationOptionNewLibraryContent": "Naujas turinys įkeltas",
"NotificationOptionPluginError": "Įskiepio klaida",
"NotificationOptionPluginInstalled": "Įskiepis įdiegtas",
- "NotificationOptionPluginUninstalled": "Įskiepis pašalintas",
+ "NotificationOptionPluginUninstalled": "Įskiepis išdiegtas",
"NotificationOptionPluginUpdateInstalled": "Įskiepio atnaujinimas įdiegtas",
"NotificationOptionServerRestartRequired": "Reikalingas serverio perleidimas",
"NotificationOptionTaskFailed": "Suplanuotos užduoties klaida",
- "NotificationOptionUserLockedOut": "Vartotojas užblokuotas",
+ "NotificationOptionUserLockedOut": "Naudotojas užblokuotas",
"NotificationOptionVideoPlayback": "Vaizdo įrašo atkūrimas pradėtas",
"NotificationOptionVideoPlaybackStopped": "Vaizdo įrašo atkūrimas sustabdytas",
"Photos": "Nuotraukos",
- "Playlists": "Grojaraštis",
- "Plugin": "Plugin",
+ "Playlists": "Grojaraščiai",
+ "Plugin": "Įskiepis",
"PluginInstalledWithName": "{0} buvo įdiegtas",
"PluginUninstalledWithName": "{0} buvo pašalintas",
"PluginUpdatedWithName": "{0} buvo atnaujintas",
- "ProviderValue": "Provider: {0}",
- "ScheduledTaskFailedWithName": "{0} klaida",
+ "ProviderValue": "Paslaugos tiekėjas: {0}",
+ "ScheduledTaskFailedWithName": "{0} nepavyko",
"ScheduledTaskStartedWithName": "{0} paleista",
"ServerNameNeedsToBeRestarted": "{0} reikia iš naujo paleisti",
"Shows": "Laidos",
@@ -76,65 +76,67 @@
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
"SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}",
"Sync": "Sinchronizuoti",
- "System": "System",
- "TvShows": "TV Serialai",
- "User": "User",
- "UserCreatedWithName": "Vartotojas {0} buvo sukurtas",
- "UserDeletedWithName": "Vartotojas {0} ištrintas",
+ "System": "Sistema",
+ "TvShows": "TV laidos",
+ "User": "Naudotojas",
+ "UserCreatedWithName": "Buvo sukurtas {0} naudotojas",
+ "UserDeletedWithName": "Naudotojas {0} ištrintas",
"UserDownloadingItemWithValues": "{0} siunčiasi {1}",
- "UserLockedOutWithName": "Vartotojas {0} užblokuotas",
+ "UserLockedOutWithName": "Naudotojas {0} užblokuotas",
"UserOfflineFromDevice": "{0} buvo atjungtas nuo {1}",
"UserOnlineFromDevice": "{0} prisijungęs iš {1}",
- "UserPasswordChangedWithName": "Slaptažodis pakeistas vartotojui {0}",
- "UserPolicyUpdatedWithName": "Vartotojo {0} teisės buvo pakeistos",
+ "UserPasswordChangedWithName": "Slaptažodis pakeistas naudotojui {0}",
+ "UserPolicyUpdatedWithName": "Naudotojo {0} teisės buvo pakeistos",
"UserStartedPlayingItemWithValues": "{0} leidžia {1} į {2}",
"UserStoppedPlayingItemWithValues": "{0} baigė leisti {1} į {2}",
"ValueHasBeenAddedToLibrary": "{0} pridėtas į mediateką",
- "ValueSpecialEpisodeName": "Ypatinga - {0}",
- "VersionNumber": "Version {0}",
- "TaskUpdatePluginsDescription": "Atsisiųsti ir įdiegti atnaujinimus priedams kuriem yra nustatytas automatiškas atnaujinimas.",
- "TaskUpdatePlugins": "Atnaujinti Priedus",
+ "ValueSpecialEpisodeName": "Ypatingų - {0}",
+ "VersionNumber": "Versija {0}",
+ "TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.",
+ "TaskUpdatePlugins": "Atnaujinti įskieius",
"TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.",
"TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.",
- "TaskCleanTranscode": "Išvalyti Perkodavimo Direktorija",
- "TaskRefreshLibraryDescription": "Ieškoti naujų failų jūsų mediatekoje ir atnaujina metaduomenis.",
- "TaskRefreshLibrary": "Skenuoti Mediateka",
+ "TaskCleanTranscode": "Išvalyti perkodavimo katalogą",
+ "TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.",
+ "TaskRefreshLibrary": "Skenuoti medijos biblioteką",
"TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus",
"TaskRefreshChannelsDescription": "Atnaujina internetinių kanalų informaciją.",
"TaskRefreshChannels": "Atnaujinti kanalus",
- "TaskRefreshPeopleDescription": "Atnaujina metaduomenis apie aktorius ir režisierius jūsų mediatekoje.",
- "TaskRefreshPeople": "Atnaujinti Žmones",
+ "TaskRefreshPeopleDescription": "Atnaujina metaduomenis apie aktorius ir režisierius jūsų medijos bibliotekoje.",
+ "TaskRefreshPeople": "Atnaujinti žmones",
"TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.",
- "TaskCleanLogs": "Išvalyti Žurnalą",
- "TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašam, kurie turi scenas.",
- "TaskRefreshChapterImages": "Ištraukti Scenų Paveikslus",
- "TaskCleanCache": "Išvalyti Talpyklą",
+ "TaskCleanLogs": "Išvalyti žurnalą",
+ "TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.",
+ "TaskRefreshChapterImages": "Ištraukti skyrių vaizdus",
+ "TaskCleanCache": "Išvalyti talpyklą",
"TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.",
- "TasksChannelsCategory": "Internetiniai Kanalai",
+ "TasksChannelsCategory": "Internetiniai kanalai",
"TasksApplicationCategory": "Programa",
- "TasksLibraryCategory": "Mediateka",
+ "TasksLibraryCategory": "Biblioteka",
"TasksMaintenanceCategory": "Priežiūra",
"TaskCleanActivityLog": "Išvalyti veiklos žurnalą",
"Undefined": "Neapibrėžtas",
- "Forced": "Priverstas",
+ "Forced": "Priverstinis",
"Default": "Numatytas",
- "TaskCleanActivityLogDescription": "Ištrina veiklos žuranlo įrašus, kurie yra senesni nei nustatytas amžius.",
+ "TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.",
"TaskOptimizeDatabase": "Optimizuoti duomenų bazę",
"TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.",
- "TaskKeyframeExtractor": "Pagrindinių kadrų išgavėjas",
+ "TaskKeyframeExtractor": "Reikšminių kadrų (KeyFrame) išgavėjas",
"TaskOptimizeDatabaseDescription": "Suspaudžia duomenų bazę ir atlaisvina vietą. Paleidžiant šią užduotį, po bibliotekos skenavimo arba kitų veiksmų kurie galimai modifikuoja duomenų bazę, gali pagerinti greitaveiką.",
"External": "Išorinis",
"HearingImpaired": "Su klausos sutrikimais",
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
"TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.",
- "TaskCleanCollectionsAndPlaylists": "Išvalo duomenis kolekcijose ir grojaraščiuose",
- "TaskCleanCollectionsAndPlaylistsDescription": "Pašalina neegzistuojančius elementus iš kolekcijų ir grojaraščių.",
- "TaskAudioNormalization": "Garso Normalizavimas",
- "TaskAudioNormalizationDescription": "Skenuoti garso normalizavimo informacijos failuose.",
+ "TaskCleanCollectionsAndPlaylists": "Išvalo duomenis rinkiniuose ir grojaraščiuose",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Pašalina neegzistuojančius elementus iš rinkinių ir grojaraščių.",
+ "TaskAudioNormalization": "Garso normalizavimas",
+ "TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
"TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus",
- "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų papildinių.",
+ "TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
"TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą",
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.",
- "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius"
+ "TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
+ "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ėgstamiausią būseną ir t. t.)."
}
diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json
index 77340a57a..55549c66d 100644
--- a/Emby.Server.Implementations/Localization/Core/lv.json
+++ b/Emby.Server.Implementations/Localization/Core/lv.json
@@ -135,5 +135,7 @@
"TaskMoveTrickplayImages": "Trickplay attēlu pārvietošana",
"TaskMoveTrickplayImagesDescription": "Pārvieto esošos trickplay failus atbilstoši bibliotēkas iestatījumiem.",
"TaskDownloadMissingLyrics": "Lejupielādēt trūkstošos vārdus",
- "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām"
+ "TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām",
+ "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."
}
diff --git a/Emby.Server.Implementations/Localization/Core/lzh.json b/Emby.Server.Implementations/Localization/Core/lzh.json
index 031a4dac7..9fb53e41d 100644
--- a/Emby.Server.Implementations/Localization/Core/lzh.json
+++ b/Emby.Server.Implementations/Localization/Core/lzh.json
@@ -2,5 +2,10 @@
"Albums": "辑册",
"Artists": "艺人",
"AuthenticationSucceededWithUserName": "{0} 授之权矣",
- "Books": "册"
+ "Books": "册",
+ "Genres": "类",
+ "HeaderAlbumArtists": "辑者",
+ "Favorites": "至爱",
+ "Folders": "箧",
+ "HeaderContinueWatching": "接目未竟"
}
diff --git a/Emby.Server.Implementations/Localization/Core/mn.json b/Emby.Server.Implementations/Localization/Core/mn.json
index 7421d42fb..a684ff204 100644
--- a/Emby.Server.Implementations/Localization/Core/mn.json
+++ b/Emby.Server.Implementations/Localization/Core/mn.json
@@ -1,14 +1,141 @@
{
- "Books": "Номууд",
- "HeaderNextUp": "Дараах",
+ "Books": "Номнууд",
+ "HeaderNextUp": "Дараа нь",
"HeaderContinueWatching": "Үргэлжлүүлэн үзэх",
"Songs": "Дуунууд",
- "Playlists": "Тоглуулах жагсаалт",
- "Movies": "Кино",
+ "Playlists": "Тоглуулах жагсаалтууд",
+ "Movies": "Кинонууд",
"Latest": "Сүүлийн үеийн",
- "Genres": "Төрөл зүйл",
+ "Genres": "Төрлүүд",
"Favorites": "Дуртай",
- "Collections": "Багц",
- "Artists": "Зураачуд",
- "Albums": "Цомгууд"
+ "Collections": "Цуглуулгууд",
+ "Artists": "Уран бүтээлчид",
+ "Albums": "Дуут цомгууд",
+ "TaskExtractMediaSegments": "Медиа сегмент шалга",
+ "TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.",
+ "TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх",
+ "TaskMoveTrickplayImagesDescription": "Одоогоор байгаа трикплэй файлуудыг сангийн тохиргоонд тохируулан шилжүүлнэ.",
+ "TaskDownloadMissingLyrics": "Алга болсон дууны үгийг татаж авах",
+ "TaskDownloadMissingLyricsDescription": "Дууны үгийг татаж авах",
+ "TaskOptimizeDatabase": "Датабаазыг сайжруулах",
+ "TaskKeyframeExtractor": "Түлхүүр кадр гаргагч",
+ "TaskCleanCache": "Кэш санг цэвэрлэх",
+ "NewVersionIsAvailable": "Jellyfin Server-н шинэ хувилбар татаж авахад нээлттэй боллоо.",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Server-н {0}-р хэсгийн тохиргоо шинэчлэгдлээ",
+ "NotificationOptionAudioPlaybackStopped": "Дууг зогсоов",
+ "NotificationOptionNewLibraryContent": "Шинэ агуулга орлоо",
+ "NotificationOptionServerRestartRequired": "Server-г дахин асаана уу",
+ "NotificationOptionVideoPlaybackStopped": "Бичлэгийг зогсоов",
+ "UserPasswordChangedWithName": "Хэрэглэгч {0}-н нууц үгийг өөрчиллөө",
+ "TaskCleanCollectionsAndPlaylists": "Цуглуулга ба тоглуулах жагсаалтыг цэвэрлэх",
+ "ScheduledTaskFailedWithName": "{0} амжилтгүй",
+ "StartupEmbyServerIsLoading": "Jellyfin Server ачааллаж байна. Хэсэг хугацааны дараа дахин оролдоно уу.",
+ "TaskCleanActivityLog": "Үйл ажиллагааны бүртгэлийг цэвэрлэх",
+ "SubtitleDownloadFailureFromForItem": "{0}-г {1}-д зориулсан хадмал орчуулгыг татаж авч чадсангүй",
+ "TaskRefreshLibraryDescription": "Таны медиа санг шинэ файлуудын хувьд шалгаж, мета мэдээллийг шинэчилнэ.",
+ "UserOfflineFromDevice": "{0}-г {1}-с салгалаа",
+ "ValueHasBeenAddedToLibrary": "{0}-г медиа сан руу нэмэгдлээ",
+ "TaskRefreshPeopleDescription": "Таны медиа санд байгаа жүжигчид болон найруулагчдын мета мэдээллийг шинэчилнэ.",
+ "TaskCleanTranscodeDescription": "Нэг өдрөөс илүү настай транскодлох файлуудыг устгана.",
+ "TaskRefreshChannelsDescription": "Интернет сувгуудын мэдээллийг шинэчлэх.",
+ "TaskDownloadMissingSubtitlesDescription": "Мета мэдээллийн тохиргоонд үндэслэн интернетээс алга болсон дэд гарчгийг хайна.",
+ "TaskOptimizeDatabaseDescription": "Мэдээллийн сантайг шахаж, чөлөөтэй зайг багасгана. Санг шалгаж, мэдээллийн сантай холбоотой өөрчлөлт хийхийн дараа энэ үйлдлийг гүйцэтгэх нь гүйцэтгэлийг сайжруулах боломжтой.",
+ "TaskKeyframeExtractorDescription": "Видео файлуудаас түлхүүр кадруудыг гаргаж, илүү нарийвчилсан HLS тоглуулах жагсаалт үүсгэнэ. Энэ үйлдэл удаан хугацаанд үргэлжлэх боломжтой.",
+ "NotificationOptionAudioPlayback": "Дууг тоглууллаа",
+ "TaskRefreshTrickplayImages": "Трикплэй зургуудыг үүсгэх",
+ "TaskUpdatePlugins": "Plugin-уудыг шинэчлэх",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Одоо байхгүй болсон зүйлсийг цуглуулга ба тоглуулах жагсаалтаас устгана.",
+ "TaskAudioNormalization": "Аудиог хэвшүүлэх",
+ "TaskAudioNormalizationDescription": "Файлуудаас дууны хэвийн хэмжээсийн мэдээллийг шалгана.",
+ "TaskRefreshTrickplayImagesDescription": "Идэвхжсэн сангуудад байгаа видеонуудын трикплэй урьдчилсан харагдацыг үүсгэнэ.",
+ "TaskUpdatePluginsDescription": "Автомат шинэчлэлд тохируулсан залгаасуудын шинэчлэлтийг татаж авч суулгана.",
+ "TaskCleanTranscode": "Транскодлох санг цэвэрлэх",
+ "TaskRefreshChannels": "Сувгуудыг шинэчлэх",
+ "TaskDownloadMissingSubtitles": "Алга болсон хадмал орчуулгыг татах",
+ "External": "Гадны",
+ "HeaderFavoriteArtists": "Дуртай уран бүтээлчид",
+ "HeaderFavoriteEpisodes": "Дуртай ангиуд",
+ "HeaderFavoriteShows": "Дуртай нэвтрүүлэг",
+ "HeaderFavoriteSongs": "Дуртай дуу",
+ "AppDeviceValues": "Aпп: {0}, Төхөөрөмж: {1}",
+ "Application": "Aпп",
+ "AuthenticationSucceededWithUserName": "{0} амжилттай нэвтэрлээ",
+ "CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа",
+ "Channels": "Сувгууд",
+ "ChapterNameValue": "{0}-р бүлэг",
+ "Default": "Анхдагч",
+ "DeviceOfflineWithName": "{0}-н холболт саллаа",
+ "DeviceOnlineWithName": "{0} холбогдлоо",
+ "FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй",
+ "Folders": "Хавтасууд",
+ "Forced": "Хүчээр",
+ "HeaderAlbumArtists": "Цомгийн уран бүтээлчид",
+ "HeaderFavoriteAlbums": "Дуртай цомгууд",
+ "HeaderLiveTV": "Шууд ТВ",
+ "HeaderRecordingGroups": "Бичлэгийн бүлгүүд",
+ "HearingImpaired": "Сонсголын бэрхшээлтэй",
+ "HomeVideos": "Үндсэн дүрсүүд",
+ "Inherit": "Уламжлах",
+ "ItemAddedWithName": "{0}-г санд нэмлээ",
+ "ItemRemovedWithName": "{0}-с сангаас хаслаа",
+ "LabelIpAddressValue": "IP хаяг: {0}",
+ "LabelRunningTimeValue": "Үргэлжлэх хугацаа: {0}",
+ "MessageApplicationUpdated": "Jellyfin Server шинэчлэгдлээ",
+ "MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ",
+ "MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ",
+ "MixedContent": "Холимог агуулга",
+ "Music": "Хөгжим",
+ "MusicVideos": "Дууны клипүүд",
+ "NameInstallFailed": "{0} суулгахад алдаа гарлаа",
+ "NameSeasonNumber": "{0}-р улирал",
+ "NameSeasonUnknown": "Улирал олдсонгүй",
+ "NotificationOptionApplicationUpdateAvailable": "Апп шинэчлэлт бий болсон байна",
+ "NotificationOptionApplicationUpdateInstalled": "Апп-н шинэчлэлийг суулгалаа",
+ "NotificationOptionCameraImageUploaded": "Камерын зураг орууллаа",
+ "NotificationOptionInstallationFailed": "Суулгалт амжилтгүй",
+ "NotificationOptionPluginError": "Plugin-д алдаа гарлаа",
+ "NotificationOptionPluginInstalled": "Plugin-г суулгалаа",
+ "NotificationOptionPluginUninstalled": "Plugin-г устгалаа",
+ "NotificationOptionPluginUpdateInstalled": "Plugin-ны шинэчлэн суулгалаа",
+ "NotificationOptionTaskFailed": "Товолсон ажил амжилтгүй",
+ "NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив",
+ "NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв",
+ "Photos": "Зургууд",
+ "Plugin": "Плагин",
+ "PluginInstalledWithName": "{0}-г суулгалаа",
+ "PluginUninstalledWithName": "{0}-г устгалаа",
+ "PluginUpdatedWithName": "{0}-г шинэчиллээ",
+ "ProviderValue": "Нийлүүлэгч: {0}",
+ "ScheduledTaskStartedWithName": "{0}-г эхлүүлэв",
+ "ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу",
+ "Shows": "Шоу",
+ "Sync": "Синхрончлох",
+ "System": "Систем",
+ "TvShows": "ТВ нэвтрүүлгүүд",
+ "Undefined": "Танисангүй",
+ "User": "Хэрэглэгч",
+ "UserCreatedWithName": "Хэрэглэгч {0}-г үүсгэлээ",
+ "UserDeletedWithName": "Хэрэглэгч {0}-г устгалаа",
+ "UserDownloadingItemWithValues": "{0} нь {1}-г татаж байна",
+ "UserLockedOutWithName": "Хэрэглэгч {0}-г түгжлээ",
+ "UserOnlineFromDevice": "{0} нь {1}-тэй холбоотой байна",
+ "UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ",
+ "UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна",
+ "UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа",
+ "ValueSpecialEpisodeName": "Онцгой - {0}",
+ "VersionNumber": "Хувилбар {0}",
+ "TasksMaintenanceCategory": "Засвар",
+ "TasksLibraryCategory": "Сан",
+ "TasksApplicationCategory": "Апп",
+ "TasksChannelsCategory": "Интернет сувгууд",
+ "TaskCleanActivityLogDescription": "Тохируулсан хугацаанаас хуучин үйл ажиллагааны бүртгэлийн бичлэгүүдийг устгана.",
+ "TaskCleanLogs": "Бүртгэлийн санг цэвэрлэх",
+ "TaskCleanLogsDescription": "{0} өдрөөс илүү настай бүртгэлийн файлуудыг устгана.",
+ "TaskRefreshPeople": "Хүмүүсийг шинэчлэх",
+ "TaskCleanCacheDescription": "Системд хэрэггүй болсон кэш файлуудыг устгана.",
+ "TaskRefreshChapterImages": "Бүлгийн зураг авах",
+ "TaskRefreshChapterImagesDescription": "Бүлгүүдтэй видеонуудын хуудсан зураг үүсгэнэ.",
+ "TaskRefreshLibrary": "Медиа санг шалгах",
+ "CleanupUserDataTask": "Хэрэглэгчийн өгөгдлийн цэвэрлэгээний үүрэг",
+ "CleanupUserDataTaskDescription": "Хугацаа нь 90 хоногоос дээш хугацаанд байхгүй болсон медианаас бүх хэрэглэгчийн өгөгдлийг (үзсэн төлөв, дуртай жагсаалт гэх мэт) цэвэрлэнэ."
}
diff --git a/Emby.Server.Implementations/Localization/Core/mr.json b/Emby.Server.Implementations/Localization/Core/mr.json
index 13c58e0ab..727bbee16 100644
--- a/Emby.Server.Implementations/Localization/Core/mr.json
+++ b/Emby.Server.Implementations/Localization/Core/mr.json
@@ -118,12 +118,24 @@
"MessageNamedServerConfigurationUpdatedWithValue": "सर्व्हर कॉन्फिगरेशन विभाग {0} अद्यतनित केला गेला आहे",
"Inherit": "वारसा",
"Forced": "सक्ती केली आहे",
- "FailedLoginAttemptWithUserName": "अयशस्वी लॉगिन {0} पासून प्रयत्न करा",
+ "FailedLoginAttemptWithUserName": "{0} कडून लॉगिन करण्याचा प्रयत्न अयशस्वी झाला",
"External": "बाहेरचा",
"DeviceOnlineWithName": "{0} कनेक्ट झाले",
"DeviceOfflineWithName": "{0} डिस्कनेक्ट झाला आहे",
"AuthenticationSucceededWithUserName": "{0} यशस्वीरित्या प्रमाणीकृत",
"HearingImpaired": "कर्णबधीर",
"TaskRefreshTrickplayImages": "ट्रिकप्ले प्रतिमा तयार करा",
- "TaskRefreshTrickplayImagesDescription": "सक्षम लायब्ररीमधील व्हिडिओंसाठी ट्रिकप्ले पूर्वावलोकन तयार करते."
+ "TaskRefreshTrickplayImagesDescription": "सक्षम लायब्ररीमधील व्हिडिओंसाठी ट्रिकप्ले पूर्वावलोकन तयार करते.",
+ "TaskCleanCollectionsAndPlaylists": "संग्रह आणि प्लेलिस्ट व्यवस्थित करा",
+ "TaskExtractMediaSegments": "मिडिया विभाग तपासणी",
+ "TaskMoveTrickplayImages": "ट्रिकप्ले प्रतिमेचे स्थान स्थलांतर करा",
+ "TaskDownloadMissingLyrics": "उपलब्ध नसलेली गीतपट्टी (Lyrics) डाउनलोड करा",
+ "TaskAudioNormalization": "ऑडिओ सामान्यीकरण",
+ "TaskAudioNormalizationDescription": "ऑडिओ सामान्यीकरणाचा डाटा स्कॅन करतो.",
+ "TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो",
+ "TaskExtractMediaSegmentsDescription": "सक्रिय असलेल्या प्लगिनमधून मीडिया विभाग प्राप्त करते.",
+ "TaskMoveTrickplayImagesDescription": "लायब्ररीच्या सेटिंग्जप्रमाणे आधीपासून अस्तित्वात असलेल्या ट्रिकप्ले फाइल्सचे स्थान बदलते.",
+ "TaskCleanCollectionsAndPlaylistsDescription": "जे संग्रह आणि प्लेलिस्ट आता अस्तित्वात नाहीत, त्यांमधील घटक हटवते.",
+ "CleanupUserDataTask": "वापरकर्ता डेटाची स्वच्छता प्रक्रिया",
+ "CleanupUserDataTaskDescription": "९० दिवसांहून अधिक काळ अनुपस्थित असलेल्या माध्यमांवरील सर्व वापरकर्ता माहिती (जसे पाहण्याची स्थिती, आवडी इ.) हटवते."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json
index a3fc7881e..971f79c2c 100644
--- a/Emby.Server.Implementations/Localization/Core/ms.json
+++ b/Emby.Server.Implementations/Localization/Core/ms.json
@@ -1,10 +1,10 @@
{
"Albums": "Album",
- "AppDeviceValues": "Apl: {0}, Peranti: {1}",
+ "AppDeviceValues": "Aplikasi: {0}, Peranti: {1}",
"Application": "Aplikasi",
- "Artists": "Artis-artis",
+ "Artists": "Artis",
"AuthenticationSucceededWithUserName": "{0} berjaya disahkan",
- "Books": "Buku-buku",
+ "Books": "Buku",
"CameraImageUploadedFrom": "Gambar baharu telah dimuat naik melalui {0}",
"Channels": "Saluran",
"ChapterNameValue": "Bab {0}",
@@ -99,7 +99,7 @@
"TasksMaintenanceCategory": "Penyelenggaraan",
"Undefined": "Tidak ditentukan",
"Forced": "Dipaksa",
- "Default": "Lalai",
+ "Default": "Default",
"TaskCleanCache": "Bersihkan Direktori Cache",
"TaskCleanActivityLogDescription": "Padamkan entri log aktiviti yang lebih tua daripada usia yang dikonfigurasi.",
"TaskRefreshPeople": "Segarkan Orang",
@@ -136,5 +136,7 @@
"TaskCleanCollectionsAndPlaylists": "Bersihkan koleksi dan senarai audio video",
"TaskAudioNormalization": "Normalisasi Audio",
"TaskAudioNormalizationDescription": "Mengimbas fail-fail untuk data normalisasi audio.",
- "TaskCleanCollectionsAndPlaylistsDescription": "Mengalih keluar item daripada koleksi dan senarai audio video yang tidak wujud lagi."
+ "TaskCleanCollectionsAndPlaylistsDescription": "Mengalih keluar item daripada koleksi dan senarai audio video yang tidak wujud lagi.",
+ "CleanupUserDataTaskDescription": "Membersihkan semua data pengguna (keadaan tontonan, status kegemaran, dan sebagainya) daripada media yang tidak lagi wujud sekurang-kurangnya selama 90 hari.",
+ "CleanupUserDataTask": "Tugas pembersihan data pengguna"
}
diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json
index b1b6e96ea..e73c56cb9 100644
--- a/Emby.Server.Implementations/Localization/Core/nb.json
+++ b/Emby.Server.Implementations/Localization/Core/nb.json
@@ -1,6 +1,6 @@
{
"Albums": "Album",
- "AppDeviceValues": "App:{0}, Enhet: {1}",
+ "AppDeviceValues": "App: {0}, Enhet: {1}",
"Application": "Program",
"Artists": "Artister",
"AuthenticationSucceededWithUserName": "{0} har logget inn",
@@ -30,7 +30,7 @@
"ItemAddedWithName": "{0} ble lagt til i biblioteket",
"ItemRemovedWithName": "{0} ble fjernet fra biblioteket",
"LabelIpAddressValue": "IP-adresse: {0}",
- "LabelRunningTimeValue": "Spilletid {0}",
+ "LabelRunningTimeValue": "Spilletid: {0}",
"Latest": "Siste",
"MessageApplicationUpdated": "Jellyfin-serveren har blitt oppdatert",
"MessageApplicationUpdatedTo": "Jellyfin-serveren ble oppdatert til {0}",
@@ -135,6 +135,8 @@
"TaskDownloadMissingLyricsDescription": "Last ned sangtekster",
"TaskExtractMediaSegments": "Skann mediasegment",
"TaskMoveTrickplayImages": "Migrer bildeplassering for Trickplay",
- "TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til bibliotekseinstillingene.",
- "TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment."
+ "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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json
index 8828eadcb..09246bd11 100644
--- a/Emby.Server.Implementations/Localization/Core/nl.json
+++ b/Emby.Server.Implementations/Localization/Core/nl.json
@@ -136,5 +136,7 @@
"TaskExtractMediaSegmentsDescription": "Verkrijgt mediasegmenten vanuit plug-ins met MediaSegment-ondersteuning.",
"TaskMoveTrickplayImages": "Locatie trickplay-afbeeldingen migreren",
"TaskMoveTrickplayImagesDescription": "Verplaatst bestaande trickplay-bestanden op basis van de bibliotheekinstellingen.",
- "TaskExtractMediaSegments": "Scannen op mediasegmenten"
+ "TaskExtractMediaSegments": "Scannen op mediasegmenten",
+ "CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig zijn.",
+ "CleanupUserDataTask": "Opruimtaak gebruikersdata"
}
diff --git a/Emby.Server.Implementations/Localization/Core/nn.json b/Emby.Server.Implementations/Localization/Core/nn.json
index ff6376258..feb5fe215 100644
--- a/Emby.Server.Implementations/Localization/Core/nn.json
+++ b/Emby.Server.Implementations/Localization/Core/nn.json
@@ -119,5 +119,7 @@
"Forced": "Tvungen",
"Default": "Standard",
"External": "Ekstern",
- "HearingImpaired": "Nedsett høyrsel"
+ "HearingImpaired": "Nedsett høyrsel",
+ "TaskRefreshTrickplayImages": "Generer Trickplay-bilete",
+ "TaskAudioNormalization": "Normalisering av lyd"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json
index 6062d9700..ced9204b4 100644
--- a/Emby.Server.Implementations/Localization/Core/pa.json
+++ b/Emby.Server.Implementations/Localization/Core/pa.json
@@ -134,6 +134,8 @@
"TaskCleanCollectionsAndPlaylistsDescription": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਵਿੱਚੋਂ ਉਹ ਆਈਟਮ ਹਟਾਉਂਦਾ ਹੈ ਜੋ ਹੁਣ ਮੌਜੂਦ ਨਹੀਂ ਹਨ।",
"TaskCleanCollectionsAndPlaylists": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਨੂੰ ਸਾਫ ਕਰੋ",
"TaskAudioNormalization": "ਆਵਾਜ਼ ਸਧਾਰਣੀਕਰਨ",
- "TaskRefreshTrickplayImagesDescription": "ਚਲ ਰਹੀ ਲਾਇਬ੍ਰੇਰੀਆਂ ਵਿੱਚ ਵੀਡੀਓਜ਼ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ।",
- "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।"
+ "TaskRefreshTrickplayImagesDescription": "ਵੀਡੀਓ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ (ਜੇ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਚੁਣਿਆ ਗਿਆ ਹੈ)।",
+ "TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।",
+ "CleanupUserDataTaskDescription": "ਘੱਟੋ-ਘੱਟ 90 ਦਿਨਾਂ ਤੋਂ ਮੌਜੂਦ ਨਾ ਹੋਣ ਵਾਲੇ ਮੀਡੀਆ ਤੋਂ ਸਾਰੇ ਉਪਭੋਗਤਾ ਡੇਟਾ (ਵਾਚ ਸਟੇਟ, ਮਨਪਸੰਦ ਸਟੇਟਸ ਆਦਿ) ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ।",
+ "CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json
index 33b0bb7e1..3555ea4ae 100644
--- a/Emby.Server.Implementations/Localization/Core/pl.json
+++ b/Emby.Server.Implementations/Localization/Core/pl.json
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "Skanowanie segmentów mediów",
"TaskMoveTrickplayImages": "Migruj lokalizację obrazu Trickplay",
"TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.",
- "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki."
+ "TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki.",
+ "CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.",
+ "CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json
index 26dc5ce82..fee7e65f1 100644
--- a/Emby.Server.Implementations/Localization/Core/pr.json
+++ b/Emby.Server.Implementations/Localization/Core/pr.json
@@ -16,7 +16,7 @@
"Collections": "Barrels",
"ItemAddedWithName": "{0} is now with yer treasure",
"Default": "Normal-like",
- "FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}",
+ "FailedLoginAttemptWithUserName": "Ye failed to enter from {0}",
"Favorites": "Finest Loot",
"ItemRemovedWithName": "{0} was taken from yer treasure",
"LabelIpAddressValue": "Ship's coordinates: {0}",
@@ -32,5 +32,91 @@
"HeaderFavoriteShows": "Treasured Tales",
"ChapterNameValue": "Piece {0}",
"HeaderFavoriteSongs": "Treasured Chimes",
- "HeaderNextUp": "Incoming"
+ "HeaderNextUp": "Incoming",
+ "HeaderLiveTV": "Scrying Glass",
+ "HearingImpaired": "Hard o' Hearing",
+ "LabelRunningTimeValue": "Journey duration: {0}",
+ "MessageApplicationUpdated": "Yer Map of the Seas has been scribbled",
+ "HomeVideos": "Yer Onboard Booty",
+ "MixedContent": "Jumbled loot",
+ "Music": "Tunes",
+ "NameInstallFailed": "Ye couldn't bring {0} aboard yer ship",
+ "MessageApplicationUpdatedTo": "Yer Map of the Seas has been scribbled with {0}",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Yer Map Drawer has been rescribbled to {0}",
+ "MessageServerConfigurationUpdated": "Yer Map drawer has been rescribbled",
+ "Inherit": "Carry on what be passed along",
+ "Latest": "Newfangled",
+ "Movies": "Moving pictures",
+ "NewVersionIsAvailable": "A fresh build o’ Jellyfin Server be waitin’ fer ye to fetch.",
+ "NotificationOptionPluginInstalled": "Plugin nailed down",
+ "NotificationOptionVideoPlayback": "Video playback be underway",
+ "ScheduledTaskFailedWithName": "{0} ran aground",
+ "StartupEmbyServerIsLoading": "Jellyfin Server be preparin’ the ship. Try yer luck again soon.",
+ "UserOfflineFromDevice": "{0} severed ties with {1}",
+ "UserDownloadingItemWithValues": "{0} be haulin’ in {1}",
+ "UserStartedPlayingItemWithValues": "{0} be playin’ {1} aboard {2}",
+ "ValueHasBeenAddedToLibrary": "{0} be stashed in yer treasure trove",
+ "TaskCleanCacheDescription": "Wipes away cache cargo no longer called fer.",
+ "TaskCleanLogsDescription": "Clears the logbook o’ entries older than {0} days.",
+ "TaskRefreshPeopleDescription": "Refreshes the charts fer actors an’ directors in yer Treasure Trove.",
+ "UserLockedOutWithName": "Matey {0} be denied boarding",
+ "TaskAudioNormalization": "Steadyin’ the shanties",
+ "TaskAudioNormalizationDescription": "Scans files fer shanty steadiyin’ data.",
+ "HeaderRecordingGroups": "Loggin' Groups",
+ "MusicVideos": "Shanty films",
+ "Playlists": "Lists o’ plunder",
+ "Plugin": "Extra sail",
+ "NotificationOptionVideoPlaybackStopped": "Video playback dropped anchor",
+ "NameSeasonNumber": "Saga {0}",
+ "NameSeasonUnknown": "Saga be Lost",
+ "NotificationOptionApplicationUpdateAvailable": "A fresh build awaits",
+ "NotificationOptionApplicationUpdateInstalled": "App upgrade be aboard",
+ "NotificationOptionAudioPlayback": "Audio playback be rollin",
+ "NotificationOptionAudioPlaybackStopped": "Audio playback dropped anchor",
+ "NotificationOptionCameraImageUploaded": "Spyglass shot be hoisted",
+ "NotificationOptionInstallationFailed": "Install be wrecked",
+ "NotificationOptionNewLibraryContent": "Fresh plunder ready to claim",
+ "NotificationOptionPluginError": "Plugin ran aground",
+ "NotificationOptionPluginUninstalled": "Plugin cast overboard",
+ "NotificationOptionPluginUpdateInstalled": "Plugin patched ‘n ready",
+ "NotificationOptionServerRestartRequired": "Server be due fer a restart",
+ "NotificationOptionTaskFailed": "Set chore went overboard",
+ "TaskRefreshLibraryDescription": "Searches the Treasure Trove fer new plunder ‘n updates the charts.",
+ "PluginInstalledWithName": "{0} nailed down",
+ "TaskCleanLogs": "Swab the Log Hold",
+ "TaskRefreshPeople": "Freshen the Mateys",
+ "PluginUninstalledWithName": "{0} sent t’ Davy Jones",
+ "PluginUpdatedWithName": "{0} patched ‘n ready",
+ "ProviderValue": "Supplier o’ goods: {0}",
+ "ScheduledTaskStartedWithName": "{0} set sail",
+ "ServerNameNeedsToBeRestarted": "{0} be cravin’ a restart",
+ "Shows": "Sagas",
+ "SubtitleDownloadFailureFromForItem": "Subtitles be sunk fetchin’ from {0} fer {1}",
+ "Sync": "Match the tides",
+ "System": "The ship’s works",
+ "TvShows": "TV Sagas",
+ "Undefined": "Uncharted",
+ "User": "Matey",
+ "UserCreatedWithName": "Matey {0} joined the crew",
+ "UserDeletedWithName": "Matey {0} cast overboard",
+ "UserOnlineFromDevice": "{0} be aboard ship from {1}",
+ "UserPasswordChangedWithName": "New passphrase set fer Matey {0}",
+ "UserPolicyUpdatedWithName": "Ship rules be changed fer {0}",
+ "UserStoppedPlayingItemWithValues": "{0} be done playin’ {1} on {2",
+ "ValueSpecialEpisodeName": "Special Tale – {0}",
+ "VersionNumber": "Edition {0}",
+ "TasksMaintenanceCategory": "Hull patchin’",
+ "TasksLibraryCategory": "Treasure Trove",
+ "TasksApplicationCategory": "Ship",
+ "TaskCleanActivityLog": "Clear the Ship’s Log",
+ "TaskCleanActivityLogDescription": "Purges ship’s logs older than the chosen time.",
+ "TaskCleanCache": "Sweep the Cache Chest",
+ "TaskRefreshChapterImages": "Claim chapter portraits",
+ "TaskRefreshChapterImagesDescription": "Paints wee portraits fer videos that own chapters.",
+ "TaskRefreshLibrary": "Scan the Treasure Trove",
+ "TasksChannelsCategory": "Channels o' thy Internet",
+ "TaskRefreshTrickplayImages": "Summon the picture tricks",
+ "TaskRefreshTrickplayImagesDescription": "Summons picture trick previews for videos in ye enabled book roost",
+ "TaskUpdatePlugins": "Resummon yer Plugins",
+ "TaskCleanTranscode": "Swab Ye Transcode Directory"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json
index 9f4f58cb6..dc5bff161 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-BR.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImagesDescription": "Move os arquivos do trickplay de acordo com as configurações da biblioteca.",
"TaskExtractMediaSegments": "Varredura do segmento de mídia",
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de mídia de plug-ins habilitados para MediaSegment.",
- "TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay"
+ "TaskMoveTrickplayImages": "Migrar o local da imagem do Trickplay",
+ "CleanupUserDataTask": "Tarefa de limpeza de dados do usuário",
+ "CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias."
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index 42ea5e0a4..c3eba362d 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -5,7 +5,7 @@
"Artists": "Artistas",
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
"Books": "Livros",
- "CameraImageUploadedFrom": "Uma nova imagem de câmara foi enviada a partir de {0}",
+ "CameraImageUploadedFrom": "Uma nova imagem da câmara foi enviada a partir de {0}",
"Channels": "Canais",
"ChapterNameValue": "Capítulo {0}",
"Collections": "Coleções",
@@ -61,7 +61,7 @@
"NotificationOptionVideoPlayback": "Reprodução do vídeo iniciada",
"NotificationOptionVideoPlaybackStopped": "Reprodução do vídeo parada",
"Photos": "Fotografias",
- "Playlists": "Listas de Reprodução",
+ "Playlists": "Playlists",
"Plugin": "Extensão",
"PluginInstalledWithName": "{0} foi instalado",
"PluginUninstalledWithName": "{0} foi desinstalado",
@@ -77,7 +77,7 @@
"SubtitleDownloadFailureFromForItem": "Falha na transferência de legendas a partir de {0} para {1}",
"Sync": "Sincronização",
"System": "Sistema",
- "TvShows": "Programas TV",
+ "TvShows": "Séries",
"User": "Utilizador",
"UserCreatedWithName": "Utilizador {0} criado",
"UserDeletedWithName": "Utilizador {0} apagado",
@@ -118,7 +118,7 @@
"TaskCleanActivityLog": "Limpar registo de atividade",
"Undefined": "Indefinido",
"Forced": "Forçado",
- "Default": "Padrão",
+ "Default": "Predefinição",
"TaskOptimizeDatabaseDescription": "Otimiza e liberta espaço livre na base de dados. A execução desta tarefa depois de analisar a mediateca ou efetuar outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.",
"TaskOptimizeDatabase": "Otimizar base de dados",
"TaskKeyframeExtractorDescription": "Extrai quadros-chave de ficheiros de video para criar listas de reprodução HLS mais precisas. Esta tarefa pode demorar algum tempo.",
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
"TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
- "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca."
+ "TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
+ "CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
+ "CleanupUserDataTask": "Limpeza de dados de utilizador"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json
index 0bf0491be..74bb1c63a 100644
--- a/Emby.Server.Implementations/Localization/Core/pt.json
+++ b/Emby.Server.Implementations/Localization/Core/pt.json
@@ -76,11 +76,11 @@
"Inherit": "Herdar",
"HomeVideos": "Vídeos Caseiros",
"HeaderRecordingGroups": "Grupos de Gravação",
- "ValueSpecialEpisodeName": "Episódio Especial - {0}",
+ "ValueSpecialEpisodeName": "Especial - {0}",
"Sync": "Sincronização",
"Songs": "Músicas",
"Shows": "Séries",
- "Playlists": "Listas de Reprodução",
+ "Playlists": "Playlists",
"Photos": "Fotografias",
"Movies": "Filmes",
"FailedLoginAttemptWithUserName": "Tentativa de início de sessão falhada a partir de {0}",
@@ -135,5 +135,7 @@
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
"TaskExtractMediaSegments": "Analisar segmentos de multimédia",
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
- "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay"
+ "TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
+ "CleanupUserDataTask": "Task de limpeza de dados do usuário",
+ "CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index a873c157e..bf71c5afa 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -98,7 +98,7 @@
"TaskCleanTranscodeDescription": "Șterge fișierele de transcodare mai vechi de o zi.",
"TaskCleanTranscode": "Curățați directorul de transcodare",
"TaskUpdatePluginsDescription": "Descarcă și instalează actualizări pentru extensiile care sunt configurate să se actualizeze automat.",
- "TaskUpdatePlugins": "Actualizați Extensile",
+ "TaskUpdatePlugins": "Actualizați Extensiile",
"TaskRefreshPeopleDescription": "Actualizează metadatele pentru actori și regizori din biblioteca media.",
"TaskRefreshPeople": "Actualizează Persoanele",
"TaskCleanLogsDescription": "Șterge fișierele jurnal care au mai mult de {0} zile.",
@@ -135,5 +135,7 @@
"TaskExtractMediaSegmentsDescription": "Extrage sau obține segmentele media de la pluginurile MediaSegment activate.",
"TaskMoveTrickplayImages": "Migrează locația imaginii Trickplay",
"TaskDownloadMissingLyrics": "Descarcă versurile lipsă",
- "TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii"
+ "TaskDownloadMissingLyricsDescription": "Descarcă versuri pentru melodii",
+ "CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului",
+ "CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json
index 856ccb1ed..1470a538c 100644
--- a/Emby.Server.Implementations/Localization/Core/ru.json
+++ b/Emby.Server.Implementations/Localization/Core/ru.json
@@ -70,7 +70,7 @@
"ScheduledTaskFailedWithName": "{0} - неудачна",
"ScheduledTaskStartedWithName": "{0} - запущена",
"ServerNameNeedsToBeRestarted": "Необходим перезапуск {0}",
- "Shows": "Телешоу",
+ "Shows": "Сериалы",
"Songs": "Композиции",
"StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.",
"SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить",
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImages": "Перенесение местоположения изображений Trickplay",
"TaskExtractMediaSegments": "Сканирование медиасегментов",
"TaskExtractMediaSegmentsDescription": "Извлекает или получает медиасегменты из плагинов MediaSegment.",
- "TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки."
+ "TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки.",
+ "CleanupUserDataTask": "Задача очистки пользовательских данных",
+ "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней."
}
diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index 66d8bf899..1de78eeae 100644
--- a/Emby.Server.Implementations/Localization/Core/sk.json
+++ b/Emby.Server.Implementations/Localization/Core/sk.json
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImages": "Presunúť umiestnenie obrázkov Trickplay",
"TaskMoveTrickplayImagesDescription": "Presunie existujúce súbory Trickplay podľa nastavení knižnice.",
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
- "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne"
+ "TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
+ "CleanupUserDataTask": "Prečistiť používateľské dáta",
+ "CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní."
}
diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json
index b17e7ae55..ff92db2f2 100644
--- a/Emby.Server.Implementations/Localization/Core/sl-SI.json
+++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json
@@ -136,5 +136,7 @@
"TaskCleanCollectionsAndPlaylists": "Počisti zbirke in sezname predvajanja",
"TaskAudioNormalization": "Normalizacija zvoka",
"TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.",
- "TaskCleanCollectionsAndPlaylistsDescription": "Odstrani elemente iz zbirk in seznamov predvajanja, ki ne obstajajo več."
+ "TaskCleanCollectionsAndPlaylistsDescription": "Odstrani elemente iz zbirk in seznamov predvajanja, ki ne obstajajo več.",
+ "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."
}
diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json
index 91ed11042..8f0079c94 100644
--- a/Emby.Server.Implementations/Localization/Core/sq.json
+++ b/Emby.Server.Implementations/Localization/Core/sq.json
@@ -125,5 +125,17 @@
"External": "Jashtem",
"HearingImpaired": "Dëgjimi i dëmtuar",
"TaskRefreshTrickplayImages": "Krijo Imazhe Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Krijon pamje paraprake për video në bibliotekat e aktivizuara."
+ "TaskRefreshTrickplayImagesDescription": "Krijon pamje paraprake për video në bibliotekat e aktivizuara.",
+ "TaskExtractMediaSegments": "Skanim i segmenteve të medias",
+ "TaskExtractMediaSegmentsDescription": "Nxjerr ose merr segmente mediaje nga shtojcat që kanë të aktivizuar MediaSegment.",
+ "TaskMoveTrickplayImages": "Migron vendndodhjen e imazheve Trickplay",
+ "TaskMoveTrickplayImagesDescription": "Zhvendos skedarët ekzistues të trickplay sipas cilësimeve të bibliotekës.",
+ "TaskDownloadMissingLyrics": "Shkarko tekstet e këngëve që mungojnë",
+ "TaskDownloadMissingLyricsDescription": "Shkarkon tekstet e këngëve",
+ "TaskCleanCollectionsAndPlaylists": "Pastron koleksionet dhe listat e këngëve",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Heq elementet nga koleksionet dhe listat e këngëve që nuk ekzistojnë më.",
+ "TaskAudioNormalization": "Normalizimi i audios",
+ "TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.",
+ "CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
+ "CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json
index af40b5e5a..76a136cf5 100644
--- a/Emby.Server.Implementations/Localization/Core/sr.json
+++ b/Emby.Server.Implementations/Localization/Core/sr.json
@@ -126,5 +126,16 @@
"HearingImpaired": "ослабљен слух",
"TaskAudioNormalization": "Нормализација звука",
"TaskCleanCollectionsAndPlaylists": "Очистите колекције и плејлисте",
- "TaskAudioNormalizationDescription": "Скенира датотеке за податке о нормализацији звука."
+ "TaskAudioNormalizationDescription": "Скенира датотеке за податке о нормализацији звука.",
+ "TaskRefreshTrickplayImages": "Направи сличице за визуелно премотавање",
+ "TaskRefreshTrickplayImagesDescription": "Прављење сличица које помажу код визуелног премотавања видео-снимака.",
+ "TaskDownloadMissingLyrics": "Преузми стихове који недостају",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Уклања ставке које више не постоје из колекција и плејлиста.",
+ "TaskExtractMediaSegments": "Скенирај сегменте медија",
+ "TaskExtractMediaSegmentsDescription": "Извлачи или добавља сегменте медија у додацима који раде са MediaSegment-ом.",
+ "TaskMoveTrickplayImagesDescription": "Премешта постојеће сличице за визуелно премотавање сходно подешавањима библиотеке.",
+ "CleanupUserDataTask": "Задатак чишћења корисничких података",
+ "CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
+ "TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
+ "TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json
index 60810b45d..1ee1a5366 100644
--- a/Emby.Server.Implementations/Localization/Core/sv.json
+++ b/Emby.Server.Implementations/Localization/Core/sv.json
@@ -136,5 +136,7 @@
"TaskExtractMediaSegments": "Skanning av mediesegment",
"TaskExtractMediaSegmentsDescription": "Extraherar eller hämtar ut mediesegmen från tillägg som stöder MediaSegment.",
"TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder",
- "TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar."
+ "TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar.",
+ "CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.",
+ "CleanupUserDataTask": "Uppgift för rensning av användardata"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json
index 7270d70fc..defdc5925 100644
--- a/Emby.Server.Implementations/Localization/Core/ta.json
+++ b/Emby.Server.Implementations/Localization/Core/ta.json
@@ -21,7 +21,7 @@
"Inherit": "மரபுரிமையாகப் பெறு",
"HeaderRecordingGroups": "பதிவு குழுக்கள்",
"Folders": "கோப்புறைகள்",
- "FailedLoginAttemptWithUserName": "{0} இன் உள்நுழைவு முயற்சி தோல்வியடைந்தது",
+ "FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது",
"DeviceOnlineWithName": "{0} இணைக்கப்பட்டது",
"DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது",
"Collections": "தொகுப்புகள்",
@@ -129,5 +129,13 @@
"TaskCleanCollectionsAndPlaylists": "சேகரிப்புகள் மற்றும் பிளேலிஸ்ட்களை சுத்தம் செய்யவும்",
"TaskCleanCollectionsAndPlaylistsDescription": "சேகரிப்புகள் மற்றும் பிளேலிஸ்ட்களில் இருந்து உருப்படிகளை நீக்குகிறது.",
"TaskAudioNormalization": "ஆடியோ இயல்பாக்கம்",
- "TaskAudioNormalizationDescription": "ஆடியோ இயல்பாக்குதல் தரவுக்காக கோப்புகளை ஸ்கேன் செய்கிறது."
+ "TaskAudioNormalizationDescription": "ஆடியோ இயல்பாக்குதல் தரவுக்காக கோப்புகளை ஸ்கேன் செய்கிறது.",
+ "TaskDownloadMissingLyrics": "விடுபட்ட பாடல் வரிகளைப் பதிவிறக்கவும்",
+ "TaskDownloadMissingLyricsDescription": "பாடல்களுக்கான வரிகளைப் பதிவிறக்குகிறது",
+ "TaskMoveTrickplayImages": "ட்ரிக்பிளே பட இருப்பிடத்தை நகர்த்து",
+ "TaskMoveTrickplayImagesDescription": "நூலக அமைப்புகளுக்கு ஏற்ப ஏற்கனவே உள்ள ட்ரிக்பிளே கோப்புகளை நகர்த்துகிறது.",
+ "TaskExtractMediaSegments": "மீடியா பிரிவு ஸ்கேன்",
+ "TaskExtractMediaSegmentsDescription": "மீடியாசெக்மென்ட் இயக்கப்பட்ட செருகுநிரல்களிலிருந்து மீடியா பிரிவுகளைப் பிரித்தெடுக்கிறது அல்லது பெறுகிறது.",
+ "CleanupUserDataTaskDescription": "குறைந்தது 90 நாட்களுக்கு இல்லாத மீடியாவிலிருந்து அனைத்து பயனர் தரவையும் (கண்காணிப்பு நிலை, பிடித்த நிலை போன்றவை) சுத்தம் செய்கிறது.",
+ "CleanupUserDataTask": "பயனர் தரவை சுத்தம் செய்யும் பணி"
}
diff --git a/Emby.Server.Implementations/Localization/Core/te.json b/Emby.Server.Implementations/Localization/Core/te.json
index 7d4422d62..ca9e34521 100644
--- a/Emby.Server.Implementations/Localization/Core/te.json
+++ b/Emby.Server.Implementations/Localization/Core/te.json
@@ -51,5 +51,14 @@
"Latest": "తాజా",
"NameInstallFailed": "{0} ఇన్‌స్టాలేషన్ విఫలమైంది",
"NameSeasonUnknown": "భాగం తెలియదు",
- "NotificationOptionApplicationUpdateAvailable": "అప్లికేషన్ అప్‌డేట్ అందుబాటులో ఉంది"
+ "NotificationOptionApplicationUpdateAvailable": "అప్లికేషన్ అప్‌డేట్ అందుబాటులో ఉంది",
+ "NameSeasonNumber": "సీజన్ {0}",
+ "NotificationOptionAudioPlaybackStopped": "ఆడియో ఆడటం ఆగిపోయింది",
+ "NotificationOptionNewLibraryContent": "కొత్త కంటెంట్ జోడించబడింది",
+ "MixedContent": "వివిధ రకాల కంటెంట్",
+ "NotificationOptionAudioPlayback": "ఆడియో ప్లే కావడం మొదలైంది",
+ "NotificationOptionCameraImageUploaded": "కెమెరా చిత్రాన్ని అప్లోడ్ చేశారు",
+ "NotificationOptionInstallationFailed": "ఇన్స్టాలేషన్ విఫలమైంది",
+ "NotificationOptionServerRestartRequired": "సర్వర్ రీస్టార్ట్ అవసరం",
+ "Inherit": "సంక్రమించు"
}
diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json
index da32e9776..113e4f30f 100644
--- a/Emby.Server.Implementations/Localization/Core/th.json
+++ b/Emby.Server.Implementations/Localization/Core/th.json
@@ -58,11 +58,11 @@
"DeviceOnlineWithName": "{0} เชื่อมต่อสำเร็จแล้ว",
"DeviceOfflineWithName": "{0} ยกเลิกการเชื่อมต่อแล้ว",
"Collections": "คอลเลกชัน",
- "ChapterNameValue": "บท {0}",
+ "ChapterNameValue": "บทที่ {0}",
"Channels": "ช่อง",
"CameraImageUploadedFrom": "ภาพถ่ายใหม่ได้ถูกอัปโหลดมาจาก {0}",
"Books": "หนังสือ",
- "AuthenticationSucceededWithUserName": "{0} ยืนยันตัวสำเร็จแล้ว",
+ "AuthenticationSucceededWithUserName": "{0} ยืนยันตัวตนสำเร็จแล้ว",
"Artists": "ศิลปิน",
"Application": "แอปพลิเคชัน",
"AppDeviceValues": "แอป: {0}, อุปกรณ์: {1}",
@@ -125,5 +125,15 @@
"TaskKeyframeExtractor": "ตัวแยกคีย์เฟรม",
"TaskKeyframeExtractorDescription": "แยกคีย์เฟรมจากไฟล์วีดีโอเพื่อสร้างรายการ HLS ให้ถูกต้อง. กระบวนการนี้อาจใช้ระยะเวลานาน",
"TaskRefreshTrickplayImages": "สร้างไฟล์รูปภาพสำหรับ Trickplay",
- "TaskRefreshTrickplayImagesDescription": "สร้างภาพตัวอย่างของวีดีโอในคลังที่เปิดใช้งาน Trickplay"
+ "TaskRefreshTrickplayImagesDescription": "สร้างภาพตัวอย่างของวีดีโอในคลังที่เปิดใช้งาน Trickplay",
+ "TaskDownloadMissingLyrics": "ดาวน์โหลดเนื้อเพลงที่หายไป",
+ "TaskDownloadMissingLyricsDescription": "ดาวน์โหลดเนื้อเพลงสำหรับเพลง",
+ "TaskAudioNormalization": "ปรับระดับเสียงให้สม่ำเสมอ",
+ "TaskAudioNormalizationDescription": "สแกนไฟล์เพื่อค้นหาข้อมูลการปรับระดับเสียงให้สม่ำเสมอ",
+ "TaskCleanCollectionsAndPlaylists": "จัดระเบียบคอลเลกชันและเพลย์ลิสต์",
+ "TaskCleanCollectionsAndPlaylistsDescription": "ลบรายการออกจากคอลเลกชันและเพลย์ลิสต์ที่ไม่มีแล้ว",
+ "TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย",
+ "TaskMoveTrickplayImagesDescription": "ย้ายไฟล์ Trickplay ตามการตั้งค่าของไลบรารี",
+ "TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment",
+ "TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay"
}
diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json
index a3cf78fcb..478111049 100644
--- a/Emby.Server.Implementations/Localization/Core/tr.json
+++ b/Emby.Server.Implementations/Localization/Core/tr.json
@@ -98,8 +98,8 @@
"TasksLibraryCategory": "Kütüphane",
"TasksMaintenanceCategory": "Bakım",
"TaskRefreshPeopleDescription": "Medya kütüphanenizdeki videoların oyuncu ve yönetmen bilgilerini günceller.",
- "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik altyazılar için internette arama yapar.",
- "TaskDownloadMissingSubtitles": "Eksik altyazıları indir",
+ "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik alt yazılar için internette arama yapar.",
+ "TaskDownloadMissingSubtitles": "Eksik alt yazıları indir",
"TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.",
"TaskRefreshChannels": "Kanalları Yenile",
"TaskCleanTranscodeDescription": "Bir günden daha eski kod dönüştürme dosyalarını siler.",
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImagesDescription": "Mevcut trickplay dosyalarını kütüphane ayarlarına göre taşır.",
"TaskDownloadMissingLyrics": "Eksik şarkı sözlerini indir",
"TaskDownloadMissingLyricsDescription": "Şarkı sözlerini indirir",
- "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır."
+ "TaskExtractMediaSegmentsDescription": "MediaSegment özelliği etkin olan eklentilerden medya segmentlerini çıkarır veya alır.",
+ "CleanupUserDataTask": "Kullanıcı verisi temizleme görevi",
+ "CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler."
}
diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json
index 3fddc2e78..3ad772aa9 100644
--- a/Emby.Server.Implementations/Localization/Core/uk.json
+++ b/Emby.Server.Implementations/Localization/Core/uk.json
@@ -135,5 +135,7 @@
"TaskMoveTrickplayImagesDescription": "Переміщує наявні Trickplay-зображення відповідно до налаштувань медіатеки.",
"TaskExtractMediaSegments": "Сканування медіа-сегментів",
"TaskMoveTrickplayImages": "Змінити місце розташування Trickplay-зображень",
- "TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment."
+ "TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment.",
+ "CleanupUserDataTask": "Завдання очищення даних користувача",
+ "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ur.json b/Emby.Server.Implementations/Localization/Core/ur.json
index 376683041..94d9c8541 100644
--- a/Emby.Server.Implementations/Localization/Core/ur.json
+++ b/Emby.Server.Implementations/Localization/Core/ur.json
@@ -1,3 +1,16 @@
{
- "Books": "کتابیں"
+ "Books": "کتابیں",
+ "AppDeviceValues": "ایپ: {0}، ڈیوائس: {1}",
+ "Albums": "البمز",
+ "Application": "ایپلی کیشن",
+ "Artists": "فنکار",
+ "AuthenticationSucceededWithUserName": "{0} کی کامیابی سے تصدیق ہو چکی ہے",
+ "CameraImageUploadedFrom": "ایک نئی کیمرے کی تصویر {0} سے اپ لوڈ کی گئی ہے",
+ "Channels": "چینلز",
+ "ChapterNameValue": "باب {0}",
+ "Collections": "مجموعے",
+ "Default": "ڈیفالٹ",
+ "DeviceOfflineWithName": "{0} نے رابطہ منقطع کر دیا ہے",
+ "DeviceOnlineWithName": "{0} منسلک ہے",
+ "External": "بیرونی"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json
index 5d3f19432..f6539adff 100644
--- a/Emby.Server.Implementations/Localization/Core/ur_PK.json
+++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json
@@ -123,5 +123,9 @@
"TaskCleanActivityLogDescription": "تشکیل شدہ عمر سے زیادہ پرانی سرگرمی لاگ اندراجات کو حذف کرتا ہے۔",
"External": "بیرونی",
"HearingImpaired": "قوت سماعت سے محروم",
- "TaskCleanActivityLog": "سرگرمی لاگ کو صاف کریں"
+ "TaskCleanActivityLog": "سرگرمی لاگ کو صاف کریں",
+ "TaskDownloadMissingLyrics": "غائب بول ڈاؤن لوڈ کریں",
+ "TaskDownloadMissingLyricsDescription": "گانے کے غائب بول ڈاؤن لوڈ کریں",
+ "TaskAudioNormalization": "آڈیو نارملائزیشن",
+ "TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔"
}
diff --git a/Emby.Server.Implementations/Localization/Core/uz.json b/Emby.Server.Implementations/Localization/Core/uz.json
index 150fb7126..e44b3f516 100644
--- a/Emby.Server.Implementations/Localization/Core/uz.json
+++ b/Emby.Server.Implementations/Localization/Core/uz.json
@@ -110,5 +110,6 @@
"TaskCleanCache": "Kesh katalogini tozalash",
"TaskRefreshChapterImages": "Sahnadan tasvirini chiqarish",
"TaskRefreshChapterImagesDescription": "Sahnalarni o'z ichiga olgan videolar uchun eskizlarni yaratadi.",
- "TaskRefreshLibrary": "Media kutubxonangizni skanerlash"
+ "TaskRefreshLibrary": "Media kutubxonangizni skanerlash",
+ "TaskCleanLogsDescription": "{0} kundan eski log fayllarni o'chiradi."
}
diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json
index f890ea74d..3f4bf1f7f 100644
--- a/Emby.Server.Implementations/Localization/Core/vi.json
+++ b/Emby.Server.Implementations/Localization/Core/vi.json
@@ -39,7 +39,7 @@
"TasksMaintenanceCategory": "Bảo Trì",
"VersionNumber": "Phiên Bản {0}",
"ValueHasBeenAddedToLibrary": "{0} đã được thêm vào thư viện của bạn",
- "UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}",
+ "UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}",
"UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}",
"UserPolicyUpdatedWithName": "Chính sách người dùng đã được cập nhật cho {0}",
"UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}",
@@ -135,5 +135,7 @@
"TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.",
"TaskMoveTrickplayImages": "Di chuyển vị trí hình ảnh Trickplay",
"TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.",
- "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện"
+ "TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện",
+ "CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng",
+ "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày."
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json
index 209b8230c..1bfa4e3c3 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-CN.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json
@@ -136,5 +136,7 @@
"TaskMoveTrickplayImages": "迁移进度条预览图的存储位置",
"TaskExtractMediaSegments": "媒体分段扫描",
"TaskExtractMediaSegmentsDescription": "从支持 MediaSegment 的插件中提取或获取媒体分段。",
- "TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。"
+ "TaskMoveTrickplayImagesDescription": "根据媒体库设置移动现有的进度条预览图文件。",
+ "CleanupUserDataTask": "用户数据清理任务",
+ "CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index 286efb7e9..c8800e256 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -23,7 +23,7 @@
"HeaderFavoriteShows": "最愛的節目",
"HeaderFavoriteSongs": "最愛的歌曲",
"HeaderLiveTV": "電視直播",
- "HeaderNextUp": "接著播放",
+ "HeaderNextUp": "繼續觀看",
"HeaderRecordingGroups": "錄製組",
"HomeVideos": "家庭影片",
"Inherit": "繼承",
@@ -127,8 +127,8 @@
"HearingImpaired": "聽力障礙",
"TaskRefreshTrickplayImages": "建立 Trickplay 圖像",
"TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。",
- "TaskExtractMediaSegments": "掃描媒體段落",
- "TaskExtractMediaSegmentsDescription": "從MediaSegment中被允許的插件獲取媒體段落。",
+ "TaskExtractMediaSegments": "掃描媒體分段資訊",
+ "TaskExtractMediaSegmentsDescription": "從允許MediaSegment 功能的插件中獲取媒體片段。",
"TaskDownloadMissingLyrics": "下載欠缺歌詞",
"TaskDownloadMissingLyricsDescription": "下載歌詞",
"TaskCleanCollectionsAndPlaylists": "整理媒體與播放清單",
@@ -136,5 +136,7 @@
"TaskAudioNormalizationDescription": "掃描檔案裏的音訊同等化資料。",
"TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。",
"TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。",
- "TaskMoveTrickplayImages": "轉移 Trickplay 影像位置"
+ "TaskMoveTrickplayImages": "轉移 Trickplay 影像位置",
+ "CleanupUserDataTask": "用戶資料清理工作",
+ "CleanupUserDataTaskDescription": "從用戶數據中清除已經被刪除超過 90 日的媒體相關資料。"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index a4ee68fc4..b3bb9106b 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -5,23 +5,23 @@
"Artists": "藝人",
"AuthenticationSucceededWithUserName": "成功授權 {0}",
"Books": "書籍",
- "CameraImageUploadedFrom": "已從 {0} 成功上傳一張相片",
+ "CameraImageUploadedFrom": "已從 {0} 成功上傳一張照片",
"Channels": "頻道",
"ChapterNameValue": "章節 {0}",
"Collections": "系列作",
"DeviceOfflineWithName": "{0} 已中斷連接",
"DeviceOnlineWithName": "{0} 已連接",
- "FailedLoginAttemptWithUserName": "來自使用者 {0} 的登入失敗嘗試",
+ "FailedLoginAttemptWithUserName": "來自 {0} 的登入失敗嘗試",
"Favorites": "我的最愛",
"Folders": "資料夾",
"Genres": "風格",
"HeaderAlbumArtists": "專輯演出者",
"HeaderContinueWatching": "繼續觀看",
"HeaderFavoriteAlbums": "最愛專輯",
- "HeaderFavoriteArtists": "最愛藝人",
- "HeaderFavoriteEpisodes": "最愛劇集",
- "HeaderFavoriteShows": "最愛節目",
- "HeaderFavoriteSongs": "最愛歌曲",
+ "HeaderFavoriteArtists": "最愛的藝人",
+ "HeaderFavoriteEpisodes": "最愛的劇集",
+ "HeaderFavoriteShows": "最愛的節目",
+ "HeaderFavoriteSongs": "最愛的歌曲",
"HeaderLiveTV": "電視直播",
"HeaderNextUp": "接下來",
"HomeVideos": "家庭影片",
@@ -135,5 +135,7 @@
"TaskExtractMediaSegments": "掃描媒體片段",
"TaskExtractMediaSegmentsDescription": "從使用媒體片段的擴充功能取得媒體片段。",
"TaskMoveTrickplayImages": "遷移快轉縮圖位置",
- "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。"
+ "TaskMoveTrickplayImagesDescription": "根據媒體庫的設定遷移快轉縮圖的檔案。",
+ "CleanupUserDataTask": "用戶資料清理工作",
+ "CleanupUserDataTaskDescription": "從用戶資料中清除已被刪除超過 90 天的媒體的相關資料。"
}
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 17db7ad4c..b4c65ad85 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -128,7 +128,8 @@ namespace Emby.Server.Implementations.Localization
}
string name = parts[3];
- if (string.IsNullOrWhiteSpace(name))
+ string displayname = parts[3];
+ if (string.IsNullOrWhiteSpace(displayname))
{
continue;
}
@@ -138,6 +139,10 @@ namespace Emby.Server.Implementations.Localization
{
continue;
}
+ else if (twoCharName.Contains('-', StringComparison.OrdinalIgnoreCase))
+ {
+ name = twoCharName;
+ }
string[] threeLetterNames;
if (string.IsNullOrWhiteSpace(parts[1]))
@@ -153,7 +158,7 @@ namespace Emby.Server.Implementations.Localization
iso6392BtoTdict.TryAdd(parts[1], parts[0]);
}
- list.Add(new CultureDto(name, name, twoCharName, threeLetterNames));
+ list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
}
_cultures = list;
@@ -520,7 +525,7 @@ namespace Emby.Server.Implementations.Localization
public bool TryGetISO6392TFromB(string isoB, [NotNullWhen(true)] out string? isoT)
{
// Unlikely case the dictionary is not (yet) initialized properly
- if (_iso6392BtoT == null)
+ if (_iso6392BtoT is null)
{
isoT = null;
return false;
diff --git a/Emby.Server.Implementations/Localization/Ratings/ar.json b/Emby.Server.Implementations/Localization/Ratings/ar.json
new file mode 100644
index 000000000..73dfd2c7c
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/ar.json
@@ -0,0 +1,41 @@
+{
+ "countryCode": "ar",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["ATP"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["+13"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["+16"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["+18"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["C"],
+ "ratingScore": {
+ "score": 1001,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/bg.json b/Emby.Server.Implementations/Localization/Ratings/bg.json
new file mode 100644
index 000000000..fa03fa9df
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/bg.json
@@ -0,0 +1,34 @@
+{
+ "countryCode": "bg",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["A","B"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["C"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["D"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["X"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/cz.json b/Emby.Server.Implementations/Localization/Ratings/cz.json
new file mode 100644
index 000000000..92fff61a2
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/cz.json
@@ -0,0 +1,34 @@
+{
+ "countryCode": "cz",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["U"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["12+"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["15+"],
+ "ratingScore": {
+ "score": 15,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["18+"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/es.json b/Emby.Server.Implementations/Localization/Ratings/es.json
index c19629939..961d64fe7 100644
--- a/Emby.Server.Implementations/Localization/Ratings/es.json
+++ b/Emby.Server.Implementations/Localization/Ratings/es.json
@@ -3,7 +3,7 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["0+", "A", "A/i", "A/fig", "A/i/fig", "APTA", "ERI", "TP"],
+ "ratingStrings": ["0+", "A", "Ai","A/i", "A/fig", "A/i/fig", "APTA", "ERI", "TP"],
"ratingScore": {
"score": 0,
"subScore": null
@@ -17,7 +17,7 @@
}
},
{
- "ratingStrings": ["7", "7/i", "7/fig", "7/i/fig"],
+ "ratingStrings": ["7", "7i", "7/i", "7/fig", "7/i/fig"],
"ratingScore": {
"score": 11,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/fi.json b/Emby.Server.Implementations/Localization/Ratings/fi.json
index 3152317b5..0d55af65c 100644
--- a/Emby.Server.Implementations/Localization/Ratings/fi.json
+++ b/Emby.Server.Implementations/Localization/Ratings/fi.json
@@ -10,32 +10,39 @@
}
},
{
- "ratingStrings": ["7", "K7"],
+ "ratingStrings": ["7", "K7", "K-7"],
"ratingScore": {
"score": 7,
"subScore": null
}
},
{
- "ratingStrings": ["12", "K12"],
+ "ratingStrings": ["12", "K12", "K-12"],
"ratingScore": {
"score": 12,
"subScore": null
}
},
{
- "ratingStrings": ["16", "K16"],
+ "ratingStrings": ["16", "K16", "K-16"],
"ratingScore": {
"score": 16,
"subScore": null
}
},
{
- "ratingStrings": ["18", "K18"],
+ "ratingStrings": ["18", "K18", "K-18"],
"ratingScore": {
"score": 18,
"subScore": null
}
+ },
+ {
+ "ratingStrings": ["KK"],
+ "ratingScore": {
+ "score": 1001,
+ "subScore": null
+ }
}
]
}
diff --git a/Emby.Server.Implementations/Localization/Ratings/gr.json b/Emby.Server.Implementations/Localization/Ratings/gr.json
new file mode 100644
index 000000000..794bf0b31
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/gr.json
@@ -0,0 +1,34 @@
+{
+ "countryCode": "gr",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["K"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["K12"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["K15"],
+ "ratingScore": {
+ "score": 15,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["K18"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/hu.json b/Emby.Server.Implementations/Localization/Ratings/hu.json
new file mode 100644
index 000000000..8043451e2
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/hu.json
@@ -0,0 +1,41 @@
+{
+ "countryCode": "hu",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["KN"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["6"],
+ "ratingScore": {
+ "score": 6,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["12"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["16"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["18", "X"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/id.json b/Emby.Server.Implementations/Localization/Ratings/id.json
new file mode 100644
index 000000000..8c687c232
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/id.json
@@ -0,0 +1,34 @@
+{
+ "countryCode": "id",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["SU"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["13+"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["17+"],
+ "ratingScore": {
+ "score": 17,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["21+"],
+ "ratingScore": {
+ "score": 21,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/in.json b/Emby.Server.Implementations/Localization/Ratings/in.json
new file mode 100644
index 000000000..d6e6f80ed
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/in.json
@@ -0,0 +1,55 @@
+{
+ "countryCode": "in",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["U"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["U/A 7+"],
+ "ratingScore": {
+ "score": 7,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["UA"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["U/A 13+"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["U/A 16+"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["A"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["S"],
+ "ratingScore": {
+ "score": 1001,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/it.json b/Emby.Server.Implementations/Localization/Ratings/it.json
new file mode 100644
index 000000000..f2889bf82
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/it.json
@@ -0,0 +1,34 @@
+{
+ "countryCode": "it",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["T"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["6+"],
+ "ratingScore": {
+ "score": 6,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["14+"],
+ "ratingScore": {
+ "score": 14,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["18+"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/kr.json b/Emby.Server.Implementations/Localization/Ratings/kr.json
new file mode 100644
index 000000000..5c416a5e4
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/kr.json
@@ -0,0 +1,41 @@
+{
+ "countryCode": "kr",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["ALL"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["12"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["15"],
+ "ratingScore": {
+ "score": 15,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["19"],
+ "ratingScore": {
+ "score": 19,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["Restricted Screening"],
+ "ratingScore": {
+ "score": 1001,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/lt.json b/Emby.Server.Implementations/Localization/Ratings/lt.json
new file mode 100644
index 000000000..c7b85a760
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/lt.json
@@ -0,0 +1,41 @@
+{
+ "countryCode": "lt",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["V"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["N-7"],
+ "ratingScore": {
+ "score": 7,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["N-13"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["N-16"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["N-18"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/nz.json b/Emby.Server.Implementations/Localization/Ratings/nz.json
index 3c1332271..23b23c8ca 100644
--- a/Emby.Server.Implementations/Localization/Ratings/nz.json
+++ b/Emby.Server.Implementations/Localization/Ratings/nz.json
@@ -24,6 +24,13 @@
}
},
{
+ "ratingStrings": ["R15"],
+ "ratingScore": {
+ "score": 15,
+ "subScore": 0
+ }
+ },
+ {
"ratingStrings": ["RP16", "M"],
"ratingScore": {
"score": 16,
diff --git a/Emby.Server.Implementations/Localization/Ratings/ph.json b/Emby.Server.Implementations/Localization/Ratings/ph.json
new file mode 100644
index 000000000..0bce9df8f
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/ph.json
@@ -0,0 +1,48 @@
+{
+ "countryCode": "ph",
+ "supportsSubScores": true,
+ "ratings": [
+ {
+ "ratingStrings": ["G"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": 0
+ }
+ },
+ {
+ "ratingStrings": ["PG"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": 0
+ }
+ },
+ {
+ "ratingStrings": ["R-13"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": 1
+ }
+ },
+ {
+ "ratingStrings": ["R-16"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": 0
+ }
+ },
+ {
+ "ratingStrings": ["R-18"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": 0
+ }
+ },
+ {
+ "ratingStrings": ["X"],
+ "ratingScore": {
+ "score": 1001,
+ "subScore": 0
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/pt.json b/Emby.Server.Implementations/Localization/Ratings/pt.json
new file mode 100644
index 000000000..2ab796c84
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/pt.json
@@ -0,0 +1,62 @@
+{
+ "countryCode": "pt",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["Públicos"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["M/3"],
+ "ratingScore": {
+ "score": 3,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["M/6"],
+ "ratingScore": {
+ "score": 6,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["M/12"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["M/14"],
+ "ratingScore": {
+ "score": 14,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["M/16"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["M/18"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["P"],
+ "ratingScore": {
+ "score": 1000,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/ro.json b/Emby.Server.Implementations/Localization/Ratings/ro.json
index 9cf735a54..aa6f7fe55 100644
--- a/Emby.Server.Implementations/Localization/Ratings/ro.json
+++ b/Emby.Server.Implementations/Localization/Ratings/ro.json
@@ -3,35 +3,35 @@
"supportsSubScores": false,
"ratings": [
{
- "ratingStrings": ["AG"],
+ "ratingStrings": ["AG", "AP"],
"ratingScore": {
"score": 0,
"subScore": null
}
},
{
- "ratingStrings": ["AP-12"],
+ "ratingStrings": ["12", "AP-12"],
"ratingScore": {
"score": 12,
"subScore": null
}
},
{
- "ratingStrings": ["N-15"],
+ "ratingStrings": ["15", "N-15"],
"ratingScore": {
"score": 15,
"subScore": null
}
},
{
- "ratingStrings": ["IM-18"],
+ "ratingStrings": ["18", "IM-18"],
"ratingScore": {
"score": 18,
"subScore": null
}
},
{
- "ratingStrings": ["IM-18-XXX"],
+ "ratingStrings": ["18+", "IM-18-XXX"],
"ratingScore": {
"score": 1000,
"subScore": null
diff --git a/Emby.Server.Implementations/Localization/Ratings/sg.json b/Emby.Server.Implementations/Localization/Ratings/sg.json
new file mode 100644
index 000000000..47d9e2833
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/sg.json
@@ -0,0 +1,48 @@
+{
+ "countryCode": "sg",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["G"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["PG"],
+ "ratingScore": {
+ "score": 7,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["PG13"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["NC16"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["M18"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["R21"],
+ "ratingScore": {
+ "score": 21,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/th.json b/Emby.Server.Implementations/Localization/Ratings/th.json
new file mode 100644
index 000000000..44bfab21c
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/th.json
@@ -0,0 +1,48 @@
+{
+ "countryCode": "th",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["P", "G"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["13"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["15"],
+ "ratingScore": {
+ "score": 15,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["18+"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["20"],
+ "ratingScore": {
+ "score": 20,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["Banned"],
+ "ratingScore": {
+ "score": 1001,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/tr.json b/Emby.Server.Implementations/Localization/Ratings/tr.json
new file mode 100644
index 000000000..5a3868856
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/tr.json
@@ -0,0 +1,69 @@
+{
+ "countryCode": "tr",
+ "supportsSubScores": true,
+ "ratings": [
+ {
+ "ratingStrings": ["Genel İzleyici Kitlesi"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": 0
+ }
+ },
+ {
+ "ratingStrings": ["6A"],
+ "ratingScore": {
+ "score": 6,
+ "subScore": 0
+ }
+ },
+ {
+ "ratingStrings": ["6+"],
+ "ratingScore": {
+ "score": 6,
+ "subScore": 1
+ }
+ },
+ {
+ "ratingStrings": ["10A"],
+ "ratingScore": {
+ "score": 10,
+ "subScore": 0
+ }
+ },
+ {
+ "ratingStrings": ["10+"],
+ "ratingScore": {
+ "score": 10,
+ "subScore": 1
+ }
+ },
+ {
+ "ratingStrings": ["13A"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": 0
+ }
+ },
+ {
+ "ratingStrings": ["13+"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": 1
+ }
+ },
+ {
+ "ratingStrings": ["16+"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": 0
+ }
+ },
+ {
+ "ratingStrings": ["18+"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": 0
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/tw.json b/Emby.Server.Implementations/Localization/Ratings/tw.json
new file mode 100644
index 000000000..a7869c122
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/tw.json
@@ -0,0 +1,41 @@
+{
+ "countryCode": "tw",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["0+"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["6+"],
+ "ratingScore": {
+ "score": 6,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["12+"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["15+"],
+ "ratingScore": {
+ "score": 15,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["18+"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/ua.json b/Emby.Server.Implementations/Localization/Ratings/ua.json
new file mode 100644
index 000000000..d8fe95168
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/ua.json
@@ -0,0 +1,34 @@
+{
+ "countryCode": "ua",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["0+"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["12+"],
+ "ratingScore": {
+ "score": 12,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["16+"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["18+"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/Ratings/za.json b/Emby.Server.Implementations/Localization/Ratings/za.json
new file mode 100644
index 000000000..fe13af797
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Ratings/za.json
@@ -0,0 +1,55 @@
+{
+ "countryCode": "za",
+ "supportsSubScores": false,
+ "ratings": [
+ {
+ "ratingStrings": ["A"],
+ "ratingScore": {
+ "score": 0,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["PG", "7-9PG"],
+ "ratingScore": {
+ "score": 7,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["10-12PG"],
+ "ratingScore": {
+ "score": 10,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["13"],
+ "ratingScore": {
+ "score": 13,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["16"],
+ "ratingScore": {
+ "score": 16,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["18"],
+ "ratingScore": {
+ "score": 18,
+ "subScore": null
+ }
+ },
+ {
+ "ratingStrings": ["X18", "XX"],
+ "ratingScore": {
+ "score": 1001,
+ "subScore": null
+ }
+ }
+ ]
+}
diff --git a/Emby.Server.Implementations/Localization/iso6392.txt b/Emby.Server.Implementations/Localization/iso6392.txt
index 00c2aee62..d5a7e866b 100644
--- a/Emby.Server.Implementations/Localization/iso6392.txt
+++ b/Emby.Server.Implementations/Localization/iso6392.txt
@@ -311,8 +311,8 @@ nia|||Nias|nias
nic|||Niger-Kordofanian languages|nigéro-kordofaniennes, langues
niu|||Niuean|niué
nld|dut|nl|Dutch; Flemish|néerlandais; flamand
-nno||nn|Norwegian Nynorsk; Nynorsk, Norwegian|norvégien nynorsk; nynorsk, norvégien
-nob||nb|Bokmål, Norwegian; Norwegian Bokmål|norvégien bokmål
+nno||nn|Norwegian (Nynorsk)|norvégien (nynorsk)
+nob||nb|Norwegian (Bokmal)|norvégien (bokmål)
nog|||Nogai|nogaï; nogay
non|||Norse, Old|norrois, vieux
nor||no|Norwegian|norvégien
@@ -347,8 +347,8 @@ pli||pi|Pali|pali
pol||pl|Polish|polonais
pon|||Pohnpeian|pohnpei
por||pt|Portuguese|portugais
-pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
-pob||pt-br|Portuguese (Brazil)|portugais (pt-br)
+por||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
+por||pt-br|Portuguese (Brazil)|portugais (pt-br)
pra|||Prakrit languages|prâkrit, langues
pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
pus||ps|Pushto; Pashto|pachto
@@ -373,7 +373,7 @@ sam|||Samaritan Aramaic|samaritain
san||sa|Sanskrit|sanskrit
sas|||Sasak|sasak
sat|||Santali|santal
-scc|srp|sr|Serbian|serbe
+srp||sr|Serbian|serbe
scn|||Sicilian|sicilien
sco|||Scots|écossais
sel|||Selkup|selkoupe
@@ -391,10 +391,10 @@ slv||sl|Slovenian|slovène
sma|||Southern Sami|sami du Sud
sme||se|Northern Sami|sami du Nord
smi|||Sami languages|sames, langues
-smj|||Lule Sami|sami de Lule
-smn|||Inari Sami|sami d'Inari
+smj|||Sami (Lule)|sami de Lule
+smn|||Sami (Inari)|sami d'Inari
smo||sm|Samoan|samoan
-sms|||Skolt Sami|sami skolt
+sms|||Sami (Skolt)|sami skolt
sna||sn|Shona|shona
snd||sd|Sindhi|sindhi
snk|||Soninke|soninké
@@ -402,8 +402,8 @@ sog|||Sogdian|sogdien
som||so|Somali|somali
son|||Songhai languages|songhai, langues
sot||st|Sotho, Southern|sotho du Sud
-spa||es-mx|Spanish; Latin|espagnol; Latin
spa||es|Spanish; Castilian|espagnol; castillan
+spa||es-419|Spanish; Latin|espagnol; Latin
sqi|alb|sq|Albanian|albanais
srd||sc|Sardinian|sarde
srn|||Sranan Tongo|sranan tongo
@@ -483,9 +483,12 @@ zen|||Zenaga|zenaga
zgh|||Standard Moroccan Tamazight|amazighe standard marocain
zha||za|Zhuang; Chuang|zhuang; chuang
zho|chi|zh|Chinese|chinois
-zho|chi|ze|Chinese; Bilingual|chinois
-zho|chi|zh-tw|Chinese; Traditional|chinois
-zho|chi|zh-hk|Chinese; Hong Kong|chinois
+zho|chi|ze|Chinese (Bilingual)|chinois
+zho|chi|zh-cn|Chinese (Simplified)|chinois
+zho|chi|zh-hans|Chinese (Simplified)|chinois
+zho|chi|zh-tw|Chinese (Traditional)|chinois
+zho|chi|zh-hant|Chinese (Traditional)|chinois
+zho|chi|zh-hk|Chinese (Hong Kong)|chinois
znd|||Zande languages|zandé, langues
zul||zu|Zulu|zoulou
zun|||Zuni|zuni
diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
deleted file mode 100644
index ea7896861..000000000
--- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
+++ /dev/null
@@ -1,272 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Jellyfin.Extensions;
-using MediaBrowser.Controller.Chapters;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Configuration;
-using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.IO;
-using Microsoft.Extensions.Logging;
-
-namespace Emby.Server.Implementations.MediaEncoder
-{
- public class EncodingManager : IEncodingManager
- {
- private readonly IFileSystem _fileSystem;
- private readonly ILogger<EncodingManager> _logger;
- private readonly IMediaEncoder _encoder;
- private readonly IChapterRepository _chapterManager;
- private readonly ILibraryManager _libraryManager;
-
- /// <summary>
- /// The first chapter ticks.
- /// </summary>
- private static readonly long _firstChapterTicks = TimeSpan.FromSeconds(15).Ticks;
-
- public EncodingManager(
- ILogger<EncodingManager> logger,
- IFileSystem fileSystem,
- IMediaEncoder encoder,
- IChapterRepository chapterManager,
- ILibraryManager libraryManager)
- {
- _logger = logger;
- _fileSystem = fileSystem;
- _encoder = encoder;
- _chapterManager = chapterManager;
- _libraryManager = libraryManager;
- }
-
- /// <summary>
- /// Gets the chapter images data path.
- /// </summary>
- /// <value>The chapter images data path.</value>
- private static string GetChapterImagesPath(BaseItem item)
- {
- return Path.Combine(item.GetInternalMetadataPath(), "chapters");
- }
-
- /// <summary>
- /// Determines whether [is eligible for chapter image extraction] [the specified video].
- /// </summary>
- /// <param name="video">The video.</param>
- /// <param name="libraryOptions">The library options for the video.</param>
- /// <returns><c>true</c> if [is eligible for chapter image extraction] [the specified video]; otherwise, <c>false</c>.</returns>
- private bool IsEligibleForChapterImageExtraction(Video video, LibraryOptions libraryOptions)
- {
- if (video.IsPlaceHolder)
- {
- return false;
- }
-
- if (libraryOptions is null || !libraryOptions.EnableChapterImageExtraction)
- {
- return false;
- }
-
- if (video.IsShortcut)
- {
- return false;
- }
-
- if (!video.IsCompleteMedia)
- {
- return false;
- }
-
- // Can't extract images if there are no video streams
- return video.DefaultVideoStreamIndex.HasValue;
- }
-
- private long GetAverageDurationBetweenChapters(IReadOnlyList<ChapterInfo> chapters)
- {
- if (chapters.Count < 2)
- {
- return 0;
- }
-
- long sum = 0;
- for (int i = 1; i < chapters.Count; i++)
- {
- sum += chapters[i].StartPositionTicks - chapters[i - 1].StartPositionTicks;
- }
-
- return sum / chapters.Count;
- }
-
- public async Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken)
- {
- if (chapters.Count == 0)
- {
- return true;
- }
-
- var libraryOptions = _libraryManager.GetLibraryOptions(video);
-
- if (!IsEligibleForChapterImageExtraction(video, libraryOptions))
- {
- extractImages = false;
- }
-
- var averageChapterDuration = GetAverageDurationBetweenChapters(chapters);
- var threshold = TimeSpan.FromSeconds(1).Ticks;
- if (averageChapterDuration < threshold)
- {
- _logger.LogInformation("Skipping chapter image extraction for {Video} as the average chapter duration {AverageDuration} was lower than the minimum threshold {Threshold}", video.Name, averageChapterDuration, threshold);
- extractImages = false;
- }
-
- var success = true;
- var changesMade = false;
-
- var runtimeTicks = video.RunTimeTicks ?? 0;
-
- var currentImages = GetSavedChapterImages(video, directoryService);
-
- foreach (var chapter in chapters)
- {
- if (chapter.StartPositionTicks >= runtimeTicks)
- {
- _logger.LogInformation("Stopping chapter extraction for {0} because a chapter was found with a position greater than the runtime.", video.Name);
- break;
- }
-
- var path = GetChapterImagePath(video, chapter.StartPositionTicks);
-
- if (!currentImages.Contains(path, StringComparison.OrdinalIgnoreCase))
- {
- if (extractImages)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- try
- {
- // Add some time for the first chapter to make sure we don't end up with a black image
- var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(_firstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks);
-
- var inputPath = video.Path;
-
- Directory.CreateDirectory(Path.GetDirectoryName(path));
-
- var container = video.Container;
- var mediaSource = new MediaSourceInfo
- {
- VideoType = video.VideoType,
- IsoType = video.IsoType,
- Protocol = video.PathProtocol.Value,
- };
-
- var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
- File.Copy(tempFile, path, true);
-
- try
- {
- _fileSystem.DeleteFile(tempFile);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting temporary chapter image encoding file {Path}", tempFile);
- }
-
- chapter.ImagePath = path;
- chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
- changesMade = true;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error extracting chapter images for {0}", string.Join(',', video.Path));
- success = false;
- break;
- }
- }
- else if (!string.IsNullOrEmpty(chapter.ImagePath))
- {
- chapter.ImagePath = null;
- changesMade = true;
- }
- }
- else if (!string.Equals(path, chapter.ImagePath, StringComparison.OrdinalIgnoreCase))
- {
- chapter.ImagePath = path;
- chapter.ImageDateModified = _fileSystem.GetLastWriteTimeUtc(path);
- changesMade = true;
- }
- else if (libraryOptions?.EnableChapterImageExtraction != true)
- {
- // We have an image for the current chapter but the user has disabled chapter image extraction -> delete this chapter's image
- chapter.ImagePath = null;
- changesMade = true;
- }
- }
-
- if (saveChapters && changesMade)
- {
- _chapterManager.SaveChapters(video.Id, chapters);
- }
-
- DeleteDeadImages(currentImages, chapters);
-
- return success;
- }
-
- private string GetChapterImagePath(Video video, long chapterPositionTicks)
- {
- var filename = video.DateModified.Ticks.ToString(CultureInfo.InvariantCulture) + "_" + chapterPositionTicks.ToString(CultureInfo.InvariantCulture) + ".jpg";
-
- return Path.Combine(GetChapterImagesPath(video), filename);
- }
-
- private static IReadOnlyList<string> GetSavedChapterImages(Video video, IDirectoryService directoryService)
- {
- var path = GetChapterImagesPath(video);
- if (!Directory.Exists(path))
- {
- return Array.Empty<string>();
- }
-
- try
- {
- return directoryService.GetFilePaths(path);
- }
- catch (IOException)
- {
- return Array.Empty<string>();
- }
- }
-
- private void DeleteDeadImages(IEnumerable<string> images, IEnumerable<ChapterInfo> chapters)
- {
- var deadImages = images
- .Except(chapters.Select(i => i.ImagePath).Where(i => !string.IsNullOrEmpty(i)), StringComparer.OrdinalIgnoreCase)
- .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i.AsSpan()), StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- foreach (var image in deadImages)
- {
- _logger.LogDebug("Deleting dead chapter image {Path}", image);
-
- try
- {
- _fileSystem.DeleteFile(image);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting {Path}.", image);
- }
- }
- }
- }
-}
diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
index 98a43b6c9..1577c5c9c 100644
--- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs
+++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
@@ -134,14 +134,16 @@ namespace Emby.Server.Implementations.Playlists
try
{
- Directory.CreateDirectory(path);
+ var info = Directory.CreateDirectory(path);
var playlist = new Playlist
{
Name = name,
Path = path,
OwnerUserId = request.UserId,
Shares = request.Users ?? [],
- OpenAccess = request.Public ?? false
+ OpenAccess = request.Public ?? false,
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc
};
playlist.SetMediaType(request.MediaType);
@@ -242,6 +244,7 @@ namespace Emby.Server.Implementations.Playlists
// Update the playlist in the repository
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
+ playlist.DateLastMediaAdded = DateTime.UtcNow;
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
@@ -312,7 +315,7 @@ namespace Emby.Server.Implementations.Playlists
return;
}
- var newPriorItemIndex = newIndex > oldIndexAccessible ? newIndex : newIndex - 1 < 0 ? 0 : newIndex - 1;
+ var newPriorItemIndex = Math.Max(newIndex - 1, 0);
var newPriorItemId = accessibleChildren[newPriorItemIndex].Item1.ItemId;
var newPriorItemIndexOnAllChildren = children.FindIndex(c => c.Item1.ItemId.Equals(newPriorItemId));
var adjustedNewIndex = DetermineAdjustedIndex(newPriorItemIndexOnAllChildren, newIndex);
diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs
index 8eeca3667..91ccb16ef 100644
--- a/Emby.Server.Implementations/Plugins/PluginManager.cs
+++ b/Emby.Server.Implementations/Plugins/PluginManager.cs
@@ -423,7 +423,7 @@ namespace Emby.Server.Implementations.Plugins
Overview = packageInfo.Overview,
Owner = packageInfo.Owner,
TargetAbi = versionInfo.TargetAbi ?? string.Empty,
- Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo.Timestamp, CultureInfo.InvariantCulture),
+ Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo.Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal),
Version = versionInfo.Version,
Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active, // Keep disabled state.
AutoUpdate = true,
diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
index 985f0a8f8..24f554981 100644
--- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
@@ -16,663 +16,662 @@ using MediaBrowser.Common.Extensions;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks
+namespace Emby.Server.Implementations.ScheduledTasks;
+
+/// <summary>
+/// Class ScheduledTaskWorker.
+/// </summary>
+public class ScheduledTaskWorker : IScheduledTaskWorker
{
+ private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly ILogger _logger;
+ private readonly ITaskManager _taskManager;
+ private readonly Lock _lastExecutionResultSyncLock = new();
+ private bool _readFromFile;
+ private TaskResult _lastExecutionResult;
+ private Task _currentTask;
+ private Tuple<TaskTriggerInfo, ITaskTrigger>[] _triggers;
+ private string _id;
+
/// <summary>
- /// Class ScheduledTaskWorker.
+ /// Initializes a new instance of the <see cref="ScheduledTaskWorker" /> class.
/// </summary>
- public class ScheduledTaskWorker : IScheduledTaskWorker
+ /// <param name="scheduledTask">The scheduled task.</param>
+ /// <param name="applicationPaths">The application paths.</param>
+ /// <param name="taskManager">The task manager.</param>
+ /// <param name="logger">The logger.</param>
+ /// <exception cref="ArgumentNullException">
+ /// scheduledTask
+ /// or
+ /// applicationPaths
+ /// or
+ /// taskManager
+ /// or
+ /// jsonSerializer
+ /// or
+ /// logger.
+ /// </exception>
+ public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger)
{
- private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
- private readonly IApplicationPaths _applicationPaths;
- private readonly ILogger _logger;
- private readonly ITaskManager _taskManager;
- private readonly Lock _lastExecutionResultSyncLock = new();
- private bool _readFromFile;
- private TaskResult _lastExecutionResult;
- private Task _currentTask;
- private Tuple<TaskTriggerInfo, ITaskTrigger>[] _triggers;
- private string _id;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="ScheduledTaskWorker" /> class.
- /// </summary>
- /// <param name="scheduledTask">The scheduled task.</param>
- /// <param name="applicationPaths">The application paths.</param>
- /// <param name="taskManager">The task manager.</param>
- /// <param name="logger">The logger.</param>
- /// <exception cref="ArgumentNullException">
- /// scheduledTask
- /// or
- /// applicationPaths
- /// or
- /// taskManager
- /// or
- /// jsonSerializer
- /// or
- /// logger.
- /// </exception>
- public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger)
- {
- ArgumentNullException.ThrowIfNull(scheduledTask);
- ArgumentNullException.ThrowIfNull(applicationPaths);
- ArgumentNullException.ThrowIfNull(taskManager);
- ArgumentNullException.ThrowIfNull(logger);
+ ArgumentNullException.ThrowIfNull(scheduledTask);
+ ArgumentNullException.ThrowIfNull(applicationPaths);
+ ArgumentNullException.ThrowIfNull(taskManager);
+ ArgumentNullException.ThrowIfNull(logger);
- ScheduledTask = scheduledTask;
- _applicationPaths = applicationPaths;
- _taskManager = taskManager;
- _logger = logger;
+ ScheduledTask = scheduledTask;
+ _applicationPaths = applicationPaths;
+ _taskManager = taskManager;
+ _logger = logger;
- InitTriggerEvents();
- }
+ InitTriggerEvents();
+ }
- /// <inheritdoc />
- public event EventHandler<GenericEventArgs<double>> TaskProgress;
+ /// <inheritdoc />
+ public event EventHandler<GenericEventArgs<double>> TaskProgress;
- /// <inheritdoc />
- public IScheduledTask ScheduledTask { get; private set; }
+ /// <inheritdoc />
+ public IScheduledTask ScheduledTask { get; private set; }
- /// <inheritdoc />
- public TaskResult LastExecutionResult
+ /// <inheritdoc />
+ public TaskResult LastExecutionResult
+ {
+ get
{
- get
- {
- var path = GetHistoryFilePath();
+ var path = GetHistoryFilePath();
- lock (_lastExecutionResultSyncLock)
+ lock (_lastExecutionResultSyncLock)
+ {
+ if (_lastExecutionResult is null && !_readFromFile)
{
- if (_lastExecutionResult is null && !_readFromFile)
+ if (File.Exists(path))
{
- if (File.Exists(path))
+ var bytes = File.ReadAllBytes(path);
+ if (bytes.Length > 0)
{
- var bytes = File.ReadAllBytes(path);
- if (bytes.Length > 0)
+ try
{
- try
- {
- _lastExecutionResult = JsonSerializer.Deserialize<TaskResult>(bytes, _jsonOptions);
- }
- catch (JsonException ex)
- {
- _logger.LogError(ex, "Error deserializing {File}", path);
- }
+ _lastExecutionResult = JsonSerializer.Deserialize<TaskResult>(bytes, _jsonOptions);
}
- else
+ catch (JsonException ex)
{
- _logger.LogDebug("Scheduled Task history file {Path} is empty. Skipping deserialization.", path);
+ _logger.LogError(ex, "Error deserializing {File}", path);
}
}
-
- _readFromFile = true;
+ else
+ {
+ _logger.LogDebug("Scheduled Task history file {Path} is empty. Skipping deserialization.", path);
+ }
}
- }
- return _lastExecutionResult;
+ _readFromFile = true;
+ }
}
- private set
- {
- _lastExecutionResult = value;
+ return _lastExecutionResult;
+ }
- var path = GetHistoryFilePath();
- Directory.CreateDirectory(Path.GetDirectoryName(path));
+ private set
+ {
+ _lastExecutionResult = value;
- lock (_lastExecutionResultSyncLock)
- {
- using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
- using Utf8JsonWriter jsonStream = new Utf8JsonWriter(createStream);
- JsonSerializer.Serialize(jsonStream, value, _jsonOptions);
- }
+ var path = GetHistoryFilePath();
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+
+ lock (_lastExecutionResultSyncLock)
+ {
+ using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
+ using Utf8JsonWriter jsonStream = new Utf8JsonWriter(createStream);
+ JsonSerializer.Serialize(jsonStream, value, _jsonOptions);
}
}
+ }
- /// <inheritdoc />
- public string Name => ScheduledTask.Name;
+ /// <inheritdoc />
+ public string Name => ScheduledTask.Name;
- /// <inheritdoc />
- public string Description => ScheduledTask.Description;
+ /// <inheritdoc />
+ public string Description => ScheduledTask.Description;
- /// <inheritdoc />
- public string Category => ScheduledTask.Category;
+ /// <inheritdoc />
+ public string Category => ScheduledTask.Category;
- /// <summary>
- /// Gets or sets the current cancellation token.
- /// </summary>
- /// <value>The current cancellation token source.</value>
- private CancellationTokenSource CurrentCancellationTokenSource { get; set; }
+ /// <summary>
+ /// Gets or sets the current cancellation token.
+ /// </summary>
+ /// <value>The current cancellation token source.</value>
+ private CancellationTokenSource CurrentCancellationTokenSource { get; set; }
- /// <summary>
- /// Gets or sets the current execution start time.
- /// </summary>
- /// <value>The current execution start time.</value>
- private DateTime CurrentExecutionStartTime { get; set; }
+ /// <summary>
+ /// Gets or sets the current execution start time.
+ /// </summary>
+ /// <value>The current execution start time.</value>
+ private DateTime CurrentExecutionStartTime { get; set; }
- /// <inheritdoc />
- public TaskState State
+ /// <inheritdoc />
+ public TaskState State
+ {
+ get
{
- get
+ if (CurrentCancellationTokenSource is not null)
{
- if (CurrentCancellationTokenSource is not null)
- {
- return CurrentCancellationTokenSource.IsCancellationRequested
- ? TaskState.Cancelling
- : TaskState.Running;
- }
-
- return TaskState.Idle;
+ return CurrentCancellationTokenSource.IsCancellationRequested
+ ? TaskState.Cancelling
+ : TaskState.Running;
}
+
+ return TaskState.Idle;
}
+ }
- /// <inheritdoc />
- public double? CurrentProgress { get; private set; }
+ /// <inheritdoc />
+ public double? CurrentProgress { get; private set; }
- /// <summary>
- /// Gets or sets the triggers that define when the task will run.
- /// </summary>
- /// <value>The triggers.</value>
- private Tuple<TaskTriggerInfo, ITaskTrigger>[] InternalTriggers
+ /// <summary>
+ /// Gets or sets the triggers that define when the task will run.
+ /// </summary>
+ /// <value>The triggers.</value>
+ private Tuple<TaskTriggerInfo, ITaskTrigger>[] InternalTriggers
+ {
+ get => _triggers;
+ set
{
- get => _triggers;
- set
- {
- ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(value);
- // Cleanup current triggers
- if (_triggers is not null)
- {
- DisposeTriggers();
- }
+ // Cleanup current triggers
+ if (_triggers is not null)
+ {
+ DisposeTriggers();
+ }
- _triggers = value.ToArray();
+ _triggers = value.ToArray();
- ReloadTriggerEvents(false);
- }
+ ReloadTriggerEvents(false);
}
+ }
- /// <inheritdoc />
- public IReadOnlyList<TaskTriggerInfo> Triggers
+ /// <inheritdoc />
+ public IReadOnlyList<TaskTriggerInfo> Triggers
+ {
+ get
{
- get
- {
- return Array.ConvertAll(InternalTriggers, i => i.Item1);
- }
+ return Array.ConvertAll(InternalTriggers, i => i.Item1);
+ }
- set
- {
- ArgumentNullException.ThrowIfNull(value);
+ set
+ {
+ ArgumentNullException.ThrowIfNull(value);
- // This null check is not great, but is needed to handle bad user input, or user mucking with the config file incorrectly
- var triggerList = value.Where(i => i is not null).ToArray();
+ // This null check is not great, but is needed to handle bad user input, or user mucking with the config file incorrectly
+ var triggerList = value.Where(i => i is not null).ToArray();
- SaveTriggers(triggerList);
+ SaveTriggers(triggerList);
- InternalTriggers = Array.ConvertAll(triggerList, i => new Tuple<TaskTriggerInfo, ITaskTrigger>(i, GetTrigger(i)));
- }
+ InternalTriggers = Array.ConvertAll(triggerList, i => new Tuple<TaskTriggerInfo, ITaskTrigger>(i, GetTrigger(i)));
}
+ }
- /// <inheritdoc />
- public string Id
+ /// <inheritdoc />
+ public string Id
+ {
+ get
{
- get
- {
- return _id ??= ScheduledTask.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture);
- }
+ return _id ??= ScheduledTask.GetType().FullName.GetMD5().ToString("N", CultureInfo.InvariantCulture);
}
+ }
- private void InitTriggerEvents()
- {
- _triggers = LoadTriggers();
- ReloadTriggerEvents(true);
- }
+ private void InitTriggerEvents()
+ {
+ _triggers = LoadTriggers();
+ ReloadTriggerEvents(true);
+ }
- /// <inheritdoc />
- public void ReloadTriggerEvents()
- {
- ReloadTriggerEvents(false);
- }
+ /// <inheritdoc />
+ public void ReloadTriggerEvents()
+ {
+ ReloadTriggerEvents(false);
+ }
- /// <summary>
- /// Reloads the trigger events.
- /// </summary>
- /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param>
- private void ReloadTriggerEvents(bool isApplicationStartup)
+ /// <summary>
+ /// Reloads the trigger events.
+ /// </summary>
+ /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param>
+ private void ReloadTriggerEvents(bool isApplicationStartup)
+ {
+ foreach (var triggerInfo in InternalTriggers)
{
- foreach (var triggerInfo in InternalTriggers)
- {
- var trigger = triggerInfo.Item2;
+ var trigger = triggerInfo.Item2;
- trigger.Stop();
+ trigger.Stop();
- trigger.Triggered -= OnTriggerTriggered;
- trigger.Triggered += OnTriggerTriggered;
- trigger.Start(LastExecutionResult, _logger, Name, isApplicationStartup);
- }
+ trigger.Triggered -= OnTriggerTriggered;
+ trigger.Triggered += OnTriggerTriggered;
+ trigger.Start(LastExecutionResult, _logger, Name, isApplicationStartup);
}
+ }
- /// <summary>
- /// Handles the Triggered event of the trigger control.
- /// </summary>
- /// <param name="sender">The source of the event.</param>
- /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
- private async void OnTriggerTriggered(object sender, EventArgs e)
- {
- var trigger = (ITaskTrigger)sender;
+ /// <summary>
+ /// Handles the Triggered event of the trigger control.
+ /// </summary>
+ /// <param name="sender">The source of the event.</param>
+ /// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
+ private async void OnTriggerTriggered(object sender, EventArgs e)
+ {
+ var trigger = (ITaskTrigger)sender;
- if (ScheduledTask is IConfigurableScheduledTask configurableTask && !configurableTask.IsEnabled)
- {
- return;
- }
+ if (ScheduledTask is IConfigurableScheduledTask configurableTask && !configurableTask.IsEnabled)
+ {
+ return;
+ }
- _logger.LogDebug("{0} fired for task: {1}", trigger.GetType().Name, Name);
+ _logger.LogDebug("{0} fired for task: {1}", trigger.GetType().Name, Name);
- trigger.Stop();
+ trigger.Stop();
- _taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions);
+ _taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions);
- await Task.Delay(1000).ConfigureAwait(false);
+ await Task.Delay(1000).ConfigureAwait(false);
- trigger.Start(LastExecutionResult, _logger, Name, false);
- }
+ trigger.Start(LastExecutionResult, _logger, Name, false);
+ }
- /// <summary>
- /// Executes the task.
- /// </summary>
- /// <param name="options">Task options.</param>
- /// <returns>Task.</returns>
- /// <exception cref="InvalidOperationException">Cannot execute a Task that is already running.</exception>
- public async Task Execute(TaskOptions options)
- {
- var task = Task.Run(async () => await ExecuteInternal(options).ConfigureAwait(false));
+ /// <summary>
+ /// Executes the task.
+ /// </summary>
+ /// <param name="options">Task options.</param>
+ /// <returns>Task.</returns>
+ /// <exception cref="InvalidOperationException">Cannot execute a Task that is already running.</exception>
+ public async Task Execute(TaskOptions options)
+ {
+ var task = Task.Run(async () => await ExecuteInternal(options).ConfigureAwait(false));
- _currentTask = task;
+ _currentTask = task;
- try
- {
- await task.ConfigureAwait(false);
- }
- finally
- {
- _currentTask = null;
- GC.Collect();
- }
+ try
+ {
+ await task.ConfigureAwait(false);
}
-
- private async Task ExecuteInternal(TaskOptions options)
+ finally
{
- // Cancel the current execution, if any
- if (CurrentCancellationTokenSource is not null)
- {
- throw new InvalidOperationException("Cannot execute a Task that is already running");
- }
-
- var progress = new Progress<double>();
+ _currentTask = null;
+ GC.Collect();
+ }
+ }
- CurrentCancellationTokenSource = new CancellationTokenSource();
+ private async Task ExecuteInternal(TaskOptions options)
+ {
+ // Cancel the current execution, if any
+ if (CurrentCancellationTokenSource is not null)
+ {
+ throw new InvalidOperationException("Cannot execute a Task that is already running");
+ }
- _logger.LogDebug("Executing {0}", Name);
+ var progress = new Progress<double>();
- ((TaskManager)_taskManager).OnTaskExecuting(this);
+ CurrentCancellationTokenSource = new CancellationTokenSource();
- progress.ProgressChanged += OnProgressChanged;
+ _logger.LogDebug("Executing {0}", Name);
- TaskCompletionStatus status;
- CurrentExecutionStartTime = DateTime.UtcNow;
+ ((TaskManager)_taskManager).OnTaskExecuting(this);
- Exception failureException = null;
+ progress.ProgressChanged += OnProgressChanged;
- try
- {
- if (options is not null && options.MaxRuntimeTicks.HasValue)
- {
- CurrentCancellationTokenSource.CancelAfter(TimeSpan.FromTicks(options.MaxRuntimeTicks.Value));
- }
+ TaskCompletionStatus status;
+ CurrentExecutionStartTime = DateTime.UtcNow;
- await ScheduledTask.ExecuteAsync(progress, CurrentCancellationTokenSource.Token).ConfigureAwait(false);
+ Exception failureException = null;
- status = TaskCompletionStatus.Completed;
- }
- catch (OperationCanceledException)
+ try
+ {
+ if (options is not null && options.MaxRuntimeTicks.HasValue)
{
- status = TaskCompletionStatus.Cancelled;
+ CurrentCancellationTokenSource.CancelAfter(TimeSpan.FromTicks(options.MaxRuntimeTicks.Value));
}
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error executing Scheduled Task");
- failureException = ex;
-
- status = TaskCompletionStatus.Failed;
- }
+ await ScheduledTask.ExecuteAsync(progress, CurrentCancellationTokenSource.Token).ConfigureAwait(false);
- var startTime = CurrentExecutionStartTime;
- var endTime = DateTime.UtcNow;
+ status = TaskCompletionStatus.Completed;
+ }
+ catch (OperationCanceledException)
+ {
+ status = TaskCompletionStatus.Cancelled;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error executing Scheduled Task");
- progress.ProgressChanged -= OnProgressChanged;
- CurrentCancellationTokenSource.Dispose();
- CurrentCancellationTokenSource = null;
- CurrentProgress = null;
+ failureException = ex;
- OnTaskCompleted(startTime, endTime, status, failureException);
+ status = TaskCompletionStatus.Failed;
}
- /// <summary>
- /// Progress_s the progress changed.
- /// </summary>
- /// <param name="sender">The sender.</param>
- /// <param name="e">The e.</param>
- private void OnProgressChanged(object sender, double e)
- {
- e = Math.Min(e, 100);
+ var startTime = CurrentExecutionStartTime;
+ var endTime = DateTime.UtcNow;
- CurrentProgress = e;
+ progress.ProgressChanged -= OnProgressChanged;
+ CurrentCancellationTokenSource.Dispose();
+ CurrentCancellationTokenSource = null;
+ CurrentProgress = null;
- TaskProgress?.Invoke(this, new GenericEventArgs<double>(e));
- }
+ OnTaskCompleted(startTime, endTime, status, failureException);
+ }
- /// <summary>
- /// Stops the task if it is currently executing.
- /// </summary>
- /// <exception cref="InvalidOperationException">Cannot cancel a Task unless it is in the Running state.</exception>
- public void Cancel()
- {
- if (State != TaskState.Running)
- {
- throw new InvalidOperationException("Cannot cancel a Task unless it is in the Running state.");
- }
+ /// <summary>
+ /// Progress_s the progress changed.
+ /// </summary>
+ /// <param name="sender">The sender.</param>
+ /// <param name="e">The e.</param>
+ private void OnProgressChanged(object sender, double e)
+ {
+ e = Math.Min(e, 100);
- CancelIfRunning();
- }
+ CurrentProgress = e;
- /// <summary>
- /// Cancels if running.
- /// </summary>
- public void CancelIfRunning()
- {
- if (State == TaskState.Running)
- {
- _logger.LogInformation("Attempting to cancel Scheduled Task {0}", Name);
- CurrentCancellationTokenSource.Cancel();
- }
- }
+ TaskProgress?.Invoke(this, new GenericEventArgs<double>(e));
+ }
- /// <summary>
- /// Gets the scheduled tasks configuration directory.
- /// </summary>
- /// <returns>System.String.</returns>
- private string GetScheduledTasksConfigurationDirectory()
+ /// <summary>
+ /// Stops the task if it is currently executing.
+ /// </summary>
+ /// <exception cref="InvalidOperationException">Cannot cancel a Task unless it is in the Running state.</exception>
+ public void Cancel()
+ {
+ if (State != TaskState.Running)
{
- return Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks");
+ throw new InvalidOperationException("Cannot cancel a Task unless it is in the Running state.");
}
- /// <summary>
- /// Gets the scheduled tasks data directory.
- /// </summary>
- /// <returns>System.String.</returns>
- private string GetScheduledTasksDataDirectory()
- {
- return Path.Combine(_applicationPaths.DataPath, "ScheduledTasks");
- }
+ CancelIfRunning();
+ }
- /// <summary>
- /// Gets the history file path.
- /// </summary>
- /// <value>The history file path.</value>
- private string GetHistoryFilePath()
+ /// <summary>
+ /// Cancels if running.
+ /// </summary>
+ public void CancelIfRunning()
+ {
+ if (State == TaskState.Running)
{
- return Path.Combine(GetScheduledTasksDataDirectory(), new Guid(Id) + ".js");
+ _logger.LogInformation("Attempting to cancel Scheduled Task {0}", Name);
+ CurrentCancellationTokenSource.Cancel();
}
+ }
- /// <summary>
- /// Gets the configuration file path.
- /// </summary>
- /// <returns>System.String.</returns>
- private string GetConfigurationFilePath()
- {
- return Path.Combine(GetScheduledTasksConfigurationDirectory(), new Guid(Id) + ".js");
- }
+ /// <summary>
+ /// Gets the scheduled tasks configuration directory.
+ /// </summary>
+ /// <returns>System.String.</returns>
+ private string GetScheduledTasksConfigurationDirectory()
+ {
+ return Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks");
+ }
- /// <summary>
- /// Loads the triggers.
- /// </summary>
- /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
- private Tuple<TaskTriggerInfo, ITaskTrigger>[] LoadTriggers()
- {
- // This null check is not great, but is needed to handle bad user input, or user mucking with the config file incorrectly
- var settings = LoadTriggerSettings().Where(i => i is not null);
+ /// <summary>
+ /// Gets the scheduled tasks data directory.
+ /// </summary>
+ /// <returns>System.String.</returns>
+ private string GetScheduledTasksDataDirectory()
+ {
+ return Path.Combine(_applicationPaths.DataPath, "ScheduledTasks");
+ }
- return settings.Select(i => new Tuple<TaskTriggerInfo, ITaskTrigger>(i, GetTrigger(i))).ToArray();
- }
+ /// <summary>
+ /// Gets the history file path.
+ /// </summary>
+ /// <value>The history file path.</value>
+ private string GetHistoryFilePath()
+ {
+ return Path.Combine(GetScheduledTasksDataDirectory(), new Guid(Id) + ".js");
+ }
- private TaskTriggerInfo[] LoadTriggerSettings()
- {
- string path = GetConfigurationFilePath();
- TaskTriggerInfo[] list = null;
- if (File.Exists(path))
- {
- var bytes = File.ReadAllBytes(path);
- list = JsonSerializer.Deserialize<TaskTriggerInfo[]>(bytes, _jsonOptions);
- }
+ /// <summary>
+ /// Gets the configuration file path.
+ /// </summary>
+ /// <returns>System.String.</returns>
+ private string GetConfigurationFilePath()
+ {
+ return Path.Combine(GetScheduledTasksConfigurationDirectory(), new Guid(Id) + ".js");
+ }
- // Return defaults if file doesn't exist.
- return list ?? GetDefaultTriggers();
- }
+ /// <summary>
+ /// Loads the triggers.
+ /// </summary>
+ /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
+ private Tuple<TaskTriggerInfo, ITaskTrigger>[] LoadTriggers()
+ {
+ // This null check is not great, but is needed to handle bad user input, or user mucking with the config file incorrectly
+ var settings = LoadTriggerSettings().Where(i => i is not null);
+
+ return settings.Select(i => new Tuple<TaskTriggerInfo, ITaskTrigger>(i, GetTrigger(i))).ToArray();
+ }
- private TaskTriggerInfo[] GetDefaultTriggers()
+ private TaskTriggerInfo[] LoadTriggerSettings()
+ {
+ string path = GetConfigurationFilePath();
+ TaskTriggerInfo[] list = null;
+ if (File.Exists(path))
{
- try
- {
- return ScheduledTask.GetDefaultTriggers().ToArray();
- }
- catch
- {
- return
- [
- new()
- {
- IntervalTicks = TimeSpan.FromDays(1).Ticks,
- Type = TaskTriggerInfoType.IntervalTrigger
- }
- ];
- }
+ var bytes = File.ReadAllBytes(path);
+ list = JsonSerializer.Deserialize<TaskTriggerInfo[]>(bytes, _jsonOptions);
}
- /// <summary>
- /// Saves the triggers.
- /// </summary>
- /// <param name="triggers">The triggers.</param>
- private void SaveTriggers(TaskTriggerInfo[] triggers)
- {
- var path = GetConfigurationFilePath();
+ // Return defaults if file doesn't exist.
+ return list ?? GetDefaultTriggers();
+ }
- Directory.CreateDirectory(Path.GetDirectoryName(path));
- using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
- using Utf8JsonWriter jsonWriter = new Utf8JsonWriter(createStream);
- JsonSerializer.Serialize(jsonWriter, triggers, _jsonOptions);
+ private TaskTriggerInfo[] GetDefaultTriggers()
+ {
+ try
+ {
+ return ScheduledTask.GetDefaultTriggers().ToArray();
}
-
- /// <summary>
- /// Called when [task completed].
- /// </summary>
- /// <param name="startTime">The start time.</param>
- /// <param name="endTime">The end time.</param>
- /// <param name="status">The status.</param>
- /// <param name="ex">The exception.</param>
- private void OnTaskCompleted(DateTime startTime, DateTime endTime, TaskCompletionStatus status, Exception ex)
+ catch
{
- var elapsedTime = endTime - startTime;
+ return
+ [
+ new()
+ {
+ IntervalTicks = TimeSpan.FromDays(1).Ticks,
+ Type = TaskTriggerInfoType.IntervalTrigger
+ }
+ ];
+ }
+ }
- _logger.LogInformation("{0} {1} after {2} minute(s) and {3} seconds", Name, status, Math.Truncate(elapsedTime.TotalMinutes), elapsedTime.Seconds);
+ /// <summary>
+ /// Saves the triggers.
+ /// </summary>
+ /// <param name="triggers">The triggers.</param>
+ private void SaveTriggers(TaskTriggerInfo[] triggers)
+ {
+ var path = GetConfigurationFilePath();
- var result = new TaskResult
- {
- StartTimeUtc = startTime,
- EndTimeUtc = endTime,
- Status = status,
- Name = Name,
- Id = Id
- };
+ Directory.CreateDirectory(Path.GetDirectoryName(path));
+ using FileStream createStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
+ using Utf8JsonWriter jsonWriter = new Utf8JsonWriter(createStream);
+ JsonSerializer.Serialize(jsonWriter, triggers, _jsonOptions);
+ }
- result.Key = ScheduledTask.Key;
+ /// <summary>
+ /// Called when [task completed].
+ /// </summary>
+ /// <param name="startTime">The start time.</param>
+ /// <param name="endTime">The end time.</param>
+ /// <param name="status">The status.</param>
+ /// <param name="ex">The exception.</param>
+ private void OnTaskCompleted(DateTime startTime, DateTime endTime, TaskCompletionStatus status, Exception ex)
+ {
+ var elapsedTime = endTime - startTime;
- if (ex is not null)
- {
- result.ErrorMessage = ex.Message;
- result.LongErrorMessage = ex.StackTrace;
- }
+ _logger.LogInformation("{0} {1} after {2} minute(s) and {3} seconds", Name, status, Math.Truncate(elapsedTime.TotalMinutes), elapsedTime.Seconds);
- LastExecutionResult = result;
+ var result = new TaskResult
+ {
+ StartTimeUtc = startTime,
+ EndTimeUtc = endTime,
+ Status = status,
+ Name = Name,
+ Id = Id
+ };
- ((TaskManager)_taskManager).OnTaskCompleted(this, result);
- }
+ result.Key = ScheduledTask.Key;
- /// <inheritdoc />
- public void Dispose()
+ if (ex is not null)
{
- Dispose(true);
- GC.SuppressFinalize(this);
+ result.ErrorMessage = ex.Message;
+ result.LongErrorMessage = ex.StackTrace;
}
- /// <summary>
- /// Releases unmanaged and - optionally - managed resources.
- /// </summary>
- /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
- protected virtual void Dispose(bool dispose)
+ LastExecutionResult = result;
+
+ ((TaskManager)_taskManager).OnTaskCompleted(this, result);
+ }
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ if (dispose)
{
- if (dispose)
- {
- DisposeTriggers();
+ DisposeTriggers();
- var wasRunning = State == TaskState.Running;
- var startTime = CurrentExecutionStartTime;
+ var wasRunning = State == TaskState.Running;
+ var startTime = CurrentExecutionStartTime;
- var token = CurrentCancellationTokenSource;
- if (token is not null)
+ var token = CurrentCancellationTokenSource;
+ if (token is not null)
+ {
+ try
{
- try
- {
- _logger.LogInformation("{Name}: Cancelling", Name);
- token.Cancel();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error calling CancellationToken.Cancel();");
- }
+ _logger.LogInformation("{Name}: Cancelling", Name);
+ token.Cancel();
}
-
- var task = _currentTask;
- if (task is not null)
+ catch (Exception ex)
{
- try
- {
- _logger.LogInformation("{Name}: Waiting on Task", Name);
- var exited = task.Wait(2000);
-
- if (exited)
- {
- _logger.LogInformation("{Name}: Task exited", Name);
- }
- else
- {
- _logger.LogInformation("{Name}: Timed out waiting for task to stop", Name);
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error calling Task.WaitAll();");
- }
+ _logger.LogError(ex, "Error calling CancellationToken.Cancel();");
}
+ }
- if (token is not null)
+ var task = _currentTask;
+ if (task is not null)
+ {
+ try
{
- try
+ _logger.LogInformation("{Name}: Waiting on Task", Name);
+ var exited = task.Wait(2000);
+
+ if (exited)
{
- _logger.LogDebug("{Name}: Disposing CancellationToken", Name);
- token.Dispose();
+ _logger.LogInformation("{Name}: Task exited", Name);
}
- catch (Exception ex)
+ else
{
- _logger.LogError(ex, "Error calling CancellationToken.Dispose();");
+ _logger.LogInformation("{Name}: Timed out waiting for task to stop", Name);
}
}
-
- if (wasRunning)
+ catch (Exception ex)
{
- OnTaskCompleted(startTime, DateTime.UtcNow, TaskCompletionStatus.Aborted, null);
+ _logger.LogError(ex, "Error calling Task.WaitAll();");
}
}
- }
-
- /// <summary>
- /// Converts a TaskTriggerInfo into a concrete BaseTaskTrigger.
- /// </summary>
- /// <param name="info">The info.</param>
- /// <returns>BaseTaskTrigger.</returns>
- /// <exception cref="ArgumentException">Invalid trigger type: + info.Type.</exception>
- private ITaskTrigger GetTrigger(TaskTriggerInfo info)
- {
- var options = new TaskOptions
- {
- MaxRuntimeTicks = info.MaxRuntimeTicks
- };
- if (info.Type == TaskTriggerInfoType.DailyTrigger)
+ if (token is not null)
{
- if (!info.TimeOfDayTicks.HasValue)
+ try
{
- throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info));
+ _logger.LogDebug("{Name}: Disposing CancellationToken", Name);
+ token.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error calling CancellationToken.Dispose();");
}
-
- return new DailyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), options);
}
- if (info.Type == TaskTriggerInfoType.WeeklyTrigger)
+ if (wasRunning)
{
- if (!info.TimeOfDayTicks.HasValue)
- {
- throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info));
- }
+ OnTaskCompleted(startTime, DateTime.UtcNow, TaskCompletionStatus.Aborted, null);
+ }
+ }
+ }
- if (!info.DayOfWeek.HasValue)
- {
- throw new ArgumentException("Info did not contain a DayOfWeek.", nameof(info));
- }
+ /// <summary>
+ /// Converts a TaskTriggerInfo into a concrete BaseTaskTrigger.
+ /// </summary>
+ /// <param name="info">The info.</param>
+ /// <returns>BaseTaskTrigger.</returns>
+ /// <exception cref="ArgumentException">Invalid trigger type: + info.Type.</exception>
+ private ITaskTrigger GetTrigger(TaskTriggerInfo info)
+ {
+ var options = new TaskOptions
+ {
+ MaxRuntimeTicks = info.MaxRuntimeTicks
+ };
- return new WeeklyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), info.DayOfWeek.Value, options);
+ if (info.Type == TaskTriggerInfoType.DailyTrigger)
+ {
+ if (!info.TimeOfDayTicks.HasValue)
+ {
+ throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info));
}
- if (info.Type == TaskTriggerInfoType.IntervalTrigger)
+ return new DailyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), options);
+ }
+
+ if (info.Type == TaskTriggerInfoType.WeeklyTrigger)
+ {
+ if (!info.TimeOfDayTicks.HasValue)
{
- if (!info.IntervalTicks.HasValue)
- {
- throw new ArgumentException("Info did not contain a IntervalTicks.", nameof(info));
- }
+ throw new ArgumentException("Info did not contain a TimeOfDayTicks.", nameof(info));
+ }
- return new IntervalTrigger(TimeSpan.FromTicks(info.IntervalTicks.Value), options);
+ if (!info.DayOfWeek.HasValue)
+ {
+ throw new ArgumentException("Info did not contain a DayOfWeek.", nameof(info));
}
- if (info.Type == TaskTriggerInfoType.StartupTrigger)
+ return new WeeklyTrigger(TimeSpan.FromTicks(info.TimeOfDayTicks.Value), info.DayOfWeek.Value, options);
+ }
+
+ if (info.Type == TaskTriggerInfoType.IntervalTrigger)
+ {
+ if (!info.IntervalTicks.HasValue)
{
- return new StartupTrigger(options);
+ throw new ArgumentException("Info did not contain a IntervalTicks.", nameof(info));
}
- throw new ArgumentException("Unrecognized trigger type: " + info.Type);
+ return new IntervalTrigger(TimeSpan.FromTicks(info.IntervalTicks.Value), options);
}
- /// <summary>
- /// Disposes each trigger.
- /// </summary>
- private void DisposeTriggers()
+ if (info.Type == TaskTriggerInfoType.StartupTrigger)
{
- foreach (var triggerInfo in InternalTriggers)
+ return new StartupTrigger(options);
+ }
+
+ throw new ArgumentException("Unrecognized trigger type: " + info.Type);
+ }
+
+ /// <summary>
+ /// Disposes each trigger.
+ /// </summary>
+ private void DisposeTriggers()
+ {
+ foreach (var triggerInfo in InternalTriggers)
+ {
+ var trigger = triggerInfo.Item2;
+ trigger.Triggered -= OnTriggerTriggered;
+ trigger.Stop();
+ if (trigger is IDisposable disposable)
{
- var trigger = triggerInfo.Item2;
- trigger.Triggered -= OnTriggerTriggered;
- trigger.Stop();
- if (trigger is IDisposable disposable)
- {
- disposable.Dispose();
- }
+ disposable.Dispose();
}
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
index a5e4104ff..4ec2c9c78 100644
--- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
@@ -8,255 +8,254 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks
+namespace Emby.Server.Implementations.ScheduledTasks;
+
+/// <summary>
+/// Class TaskManager.
+/// </summary>
+public class TaskManager : ITaskManager
{
/// <summary>
- /// Class TaskManager.
+ /// The _task queue.
/// </summary>
- public class TaskManager : ITaskManager
- {
- /// <summary>
- /// The _task queue.
- /// </summary>
- private readonly ConcurrentQueue<Tuple<Type, TaskOptions>> _taskQueue =
- new ConcurrentQueue<Tuple<Type, TaskOptions>>();
-
- private readonly IApplicationPaths _applicationPaths;
- private readonly ILogger<TaskManager> _logger;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="TaskManager" /> class.
- /// </summary>
- /// <param name="applicationPaths">The application paths.</param>
- /// <param name="logger">The logger.</param>
- public TaskManager(
- IApplicationPaths applicationPaths,
- ILogger<TaskManager> logger)
- {
- _applicationPaths = applicationPaths;
- _logger = logger;
+ private readonly ConcurrentQueue<Tuple<Type, TaskOptions>> _taskQueue =
+ new ConcurrentQueue<Tuple<Type, TaskOptions>>();
- ScheduledTasks = Array.Empty<IScheduledTaskWorker>();
- }
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly ILogger<TaskManager> _logger;
- /// <inheritdoc />
- public event EventHandler<GenericEventArgs<IScheduledTaskWorker>>? TaskExecuting;
+ /// <summary>
+ /// Initializes a new instance of the <see cref="TaskManager" /> class.
+ /// </summary>
+ /// <param name="applicationPaths">The application paths.</param>
+ /// <param name="logger">The logger.</param>
+ public TaskManager(
+ IApplicationPaths applicationPaths,
+ ILogger<TaskManager> logger)
+ {
+ _applicationPaths = applicationPaths;
+ _logger = logger;
- /// <inheritdoc />
- public event EventHandler<TaskCompletionEventArgs>? TaskCompleted;
+ ScheduledTasks = [];
+ }
- /// <inheritdoc />
- public IReadOnlyList<IScheduledTaskWorker> ScheduledTasks { get; private set; }
+ /// <inheritdoc />
+ public event EventHandler<GenericEventArgs<IScheduledTaskWorker>>? TaskExecuting;
- /// <inheritdoc />
- public void CancelIfRunningAndQueue<T>(TaskOptions options)
- where T : IScheduledTask
- {
- var task = ScheduledTasks.First(t => t.ScheduledTask.GetType() == typeof(T));
- ((ScheduledTaskWorker)task).CancelIfRunning();
+ /// <inheritdoc />
+ public event EventHandler<TaskCompletionEventArgs>? TaskCompleted;
- QueueScheduledTask<T>(options);
- }
+ /// <inheritdoc />
+ public IReadOnlyList<IScheduledTaskWorker> ScheduledTasks { get; private set; }
- /// <inheritdoc />
- public void CancelIfRunningAndQueue<T>()
- where T : IScheduledTask
- {
- CancelIfRunningAndQueue<T>(new TaskOptions());
- }
+ /// <inheritdoc />
+ public void CancelIfRunningAndQueue<T>(TaskOptions options)
+ where T : IScheduledTask
+ {
+ var task = ScheduledTasks.First(t => t.ScheduledTask.GetType() == typeof(T));
+ ((ScheduledTaskWorker)task).CancelIfRunning();
- /// <inheritdoc />
- public void CancelIfRunning<T>()
- where T : IScheduledTask
- {
- var task = ScheduledTasks.First(t => t.ScheduledTask.GetType() == typeof(T));
- ((ScheduledTaskWorker)task).CancelIfRunning();
- }
+ QueueScheduledTask<T>(options);
+ }
- /// <inheritdoc />
- public void QueueScheduledTask<T>(TaskOptions options)
+ /// <inheritdoc />
+ public void CancelIfRunningAndQueue<T>()
where T : IScheduledTask
- {
- var scheduledTask = ScheduledTasks.FirstOrDefault(t => t.ScheduledTask.GetType() == typeof(T));
+ {
+ CancelIfRunningAndQueue<T>(new TaskOptions());
+ }
- if (scheduledTask is null)
- {
- _logger.LogError("Unable to find scheduled task of type {0} in QueueScheduledTask.", typeof(T).Name);
- }
- else
- {
- QueueScheduledTask(scheduledTask, options);
- }
- }
+ /// <inheritdoc />
+ public void CancelIfRunning<T>()
+ where T : IScheduledTask
+ {
+ var task = ScheduledTasks.First(t => t.ScheduledTask.GetType() == typeof(T));
+ ((ScheduledTaskWorker)task).CancelIfRunning();
+ }
- /// <inheritdoc />
- public void QueueScheduledTask<T>()
- where T : IScheduledTask
- {
- QueueScheduledTask<T>(new TaskOptions());
- }
+ /// <inheritdoc />
+ public void QueueScheduledTask<T>(TaskOptions options)
+ where T : IScheduledTask
+ {
+ var scheduledTask = ScheduledTasks.FirstOrDefault(t => t.ScheduledTask.GetType() == typeof(T));
- /// <inheritdoc />
- public void QueueIfNotRunning<T>()
- where T : IScheduledTask
+ if (scheduledTask is null)
{
- var task = ScheduledTasks.First(t => t.ScheduledTask.GetType() == typeof(T));
-
- if (task.State != TaskState.Running)
- {
- QueueScheduledTask<T>(new TaskOptions());
- }
+ _logger.LogError("Unable to find scheduled task of type {Type} in QueueScheduledTask.", typeof(T).Name);
}
-
- /// <inheritdoc />
- public void Execute<T>()
- where T : IScheduledTask
+ else
{
- var scheduledTask = ScheduledTasks.FirstOrDefault(t => t.ScheduledTask.GetType() == typeof(T));
+ QueueScheduledTask(scheduledTask, options);
+ }
+ }
- if (scheduledTask is null)
- {
- _logger.LogError("Unable to find scheduled task of type {0} in Execute.", typeof(T).Name);
- }
- else
- {
- var type = scheduledTask.ScheduledTask.GetType();
+ /// <inheritdoc />
+ public void QueueScheduledTask<T>()
+ where T : IScheduledTask
+ {
+ QueueScheduledTask<T>(new TaskOptions());
+ }
- _logger.LogDebug("Queuing task {0}", type.Name);
+ /// <inheritdoc />
+ public void QueueIfNotRunning<T>()
+ where T : IScheduledTask
+ {
+ var task = ScheduledTasks.First(t => t.ScheduledTask.GetType() == typeof(T));
- lock (_taskQueue)
- {
- if (scheduledTask.State == TaskState.Idle)
- {
- Execute(scheduledTask, new TaskOptions());
- }
- }
- }
+ if (task.State != TaskState.Running)
+ {
+ QueueScheduledTask<T>(new TaskOptions());
}
+ }
- /// <inheritdoc />
- public void QueueScheduledTask(IScheduledTask task, TaskOptions options)
- {
- var scheduledTask = ScheduledTasks.FirstOrDefault(t => t.ScheduledTask.GetType() == task.GetType());
+ /// <inheritdoc />
+ public void Execute<T>()
+ where T : IScheduledTask
+ {
+ var scheduledTask = ScheduledTasks.FirstOrDefault(t => t.ScheduledTask.GetType() == typeof(T));
- if (scheduledTask is null)
- {
- _logger.LogError("Unable to find scheduled task of type {0} in QueueScheduledTask.", task.GetType().Name);
- }
- else
- {
- QueueScheduledTask(scheduledTask, options);
- }
+ if (scheduledTask is null)
+ {
+ _logger.LogError("Unable to find scheduled task of type {Type} in Execute.", typeof(T).Name);
}
-
- /// <summary>
- /// Queues the scheduled task.
- /// </summary>
- /// <param name="task">The task.</param>
- /// <param name="options">The task options.</param>
- private void QueueScheduledTask(IScheduledTaskWorker task, TaskOptions options)
+ else
{
- var type = task.ScheduledTask.GetType();
+ var type = scheduledTask.ScheduledTask.GetType();
- _logger.LogDebug("Queuing task {0}", type.Name);
+ _logger.LogDebug("Queuing task {Name}", type.Name);
lock (_taskQueue)
{
- if (task.State == TaskState.Idle)
+ if (scheduledTask.State == TaskState.Idle)
{
- Execute(task, options);
- return;
+ Execute(scheduledTask, new TaskOptions());
}
-
- _taskQueue.Enqueue(new Tuple<Type, TaskOptions>(type, options));
}
}
+ }
- /// <inheritdoc />
- public void AddTasks(IEnumerable<IScheduledTask> tasks)
- {
- var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger));
+ /// <inheritdoc />
+ public void QueueScheduledTask(IScheduledTask task, TaskOptions options)
+ {
+ var scheduledTask = ScheduledTasks.FirstOrDefault(t => t.ScheduledTask.GetType() == task.GetType());
- ScheduledTasks = ScheduledTasks.Concat(list).ToArray();
+ if (scheduledTask is null)
+ {
+ _logger.LogError("Unable to find scheduled task of type {Type} in QueueScheduledTask.", task.GetType().Name);
}
-
- /// <inheritdoc />
- public void Dispose()
+ else
{
- Dispose(true);
- GC.SuppressFinalize(this);
+ QueueScheduledTask(scheduledTask, options);
}
+ }
+
+ /// <summary>
+ /// Queues the scheduled task.
+ /// </summary>
+ /// <param name="task">The task.</param>
+ /// <param name="options">The task options.</param>
+ private void QueueScheduledTask(IScheduledTaskWorker task, TaskOptions options)
+ {
+ var type = task.ScheduledTask.GetType();
+
+ _logger.LogDebug("Queuing task {Name}", type.Name);
- /// <summary>
- /// Releases unmanaged and - optionally - managed resources.
- /// </summary>
- /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
- protected virtual void Dispose(bool dispose)
+ lock (_taskQueue)
{
- foreach (var task in ScheduledTasks)
+ if (task.State == TaskState.Idle)
{
- task.Dispose();
+ Execute(task, options);
+ return;
}
- }
- /// <inheritdoc />
- public void Cancel(IScheduledTaskWorker task)
- {
- ((ScheduledTaskWorker)task).Cancel();
+ _taskQueue.Enqueue(new Tuple<Type, TaskOptions>(type, options));
}
+ }
- /// <inheritdoc />
- public Task Execute(IScheduledTaskWorker task, TaskOptions options)
- {
- return ((ScheduledTaskWorker)task).Execute(options);
- }
+ /// <inheritdoc />
+ public void AddTasks(IEnumerable<IScheduledTask> tasks)
+ {
+ var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger));
+
+ ScheduledTasks = ScheduledTasks.Concat(list).ToArray();
+ }
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
- /// <summary>
- /// Called when [task executing].
- /// </summary>
- /// <param name="task">The task.</param>
- internal void OnTaskExecuting(IScheduledTaskWorker task)
+ /// <summary>
+ /// Releases unmanaged and - optionally - managed resources.
+ /// </summary>
+ /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool dispose)
+ {
+ foreach (var task in ScheduledTasks)
{
- TaskExecuting?.Invoke(this, new GenericEventArgs<IScheduledTaskWorker>(task));
+ task.Dispose();
}
+ }
- /// <summary>
- /// Called when [task completed].
- /// </summary>
- /// <param name="task">The task.</param>
- /// <param name="result">The result.</param>
- internal void OnTaskCompleted(IScheduledTaskWorker task, TaskResult result)
- {
- TaskCompleted?.Invoke(task, new TaskCompletionEventArgs(task, result));
+ /// <inheritdoc />
+ public void Cancel(IScheduledTaskWorker task)
+ {
+ ((ScheduledTaskWorker)task).Cancel();
+ }
- ExecuteQueuedTasks();
- }
+ /// <inheritdoc />
+ public Task Execute(IScheduledTaskWorker task, TaskOptions options)
+ {
+ return ((ScheduledTaskWorker)task).Execute(options);
+ }
+
+ /// <summary>
+ /// Called when [task executing].
+ /// </summary>
+ /// <param name="task">The task.</param>
+ internal void OnTaskExecuting(IScheduledTaskWorker task)
+ {
+ TaskExecuting?.Invoke(this, new GenericEventArgs<IScheduledTaskWorker>(task));
+ }
+
+ /// <summary>
+ /// Called when [task completed].
+ /// </summary>
+ /// <param name="task">The task.</param>
+ /// <param name="result">The result.</param>
+ internal void OnTaskCompleted(IScheduledTaskWorker task, TaskResult result)
+ {
+ TaskCompleted?.Invoke(task, new TaskCompletionEventArgs(task, result));
+
+ ExecuteQueuedTasks();
+ }
- /// <summary>
- /// Executes the queued tasks.
- /// </summary>
- private void ExecuteQueuedTasks()
+ /// <summary>
+ /// Executes the queued tasks.
+ /// </summary>
+ private void ExecuteQueuedTasks()
+ {
+ lock (_taskQueue)
{
- lock (_taskQueue)
- {
- var list = new List<Tuple<Type, TaskOptions>>();
+ var list = new List<Tuple<Type, TaskOptions>>();
- while (_taskQueue.TryDequeue(out var item))
+ while (_taskQueue.TryDequeue(out var item))
+ {
+ if (list.All(i => i.Item1 != item.Item1))
{
- if (list.All(i => i.Item1 != item.Item1))
- {
- list.Add(item);
- }
+ list.Add(item);
}
+ }
- foreach (var enqueuedType in list)
- {
- var scheduledTask = ScheduledTasks.First(t => t.ScheduledTask.GetType() == enqueuedType.Item1);
+ foreach (var enqueuedType in list)
+ {
+ var scheduledTask = ScheduledTasks.First(t => t.ScheduledTask.GetType() == enqueuedType.Item1);
- if (scheduledTask.State == TaskState.Idle)
- {
- Execute(scheduledTask, enqueuedType.Item2);
- }
+ if (scheduledTask.State == TaskState.Idle)
+ {
+ Execute(scheduledTask, enqueuedType.Item2);
}
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
index 8d1d509ff..36708e258 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
@@ -33,6 +33,8 @@ public partial class AudioNormalizationTask : IScheduledTask
private readonly ILocalizationManager _localization;
private readonly ILogger<AudioNormalizationTask> _logger;
+ private static readonly TimeSpan _dbSaveInterval = TimeSpan.FromMinutes(5);
+
/// <summary>
/// Initializes a new instance of the <see cref="AudioNormalizationTask"/> class.
/// </summary>
@@ -76,94 +78,150 @@ public partial class AudioNormalizationTask : IScheduledTask
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
- foreach (var library in _libraryManager.RootFolder.Children)
+ var numComplete = 0;
+ var libraries = _libraryManager.RootFolder.Children.Where(library => _libraryManager.GetLibraryOptions(library).EnableLUFSScan).ToArray();
+ double percent = 0;
+
+ foreach (var library in libraries)
{
- var libraryOptions = _libraryManager.GetLibraryOptions(library);
- if (!libraryOptions.EnableLUFSScan)
- {
- continue;
- }
+ var startDbSaveInterval = Stopwatch.GetTimestamp();
+ var albums = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Parent = library, Recursive = true });
+ var toSaveDbItems = new List<BaseItem>();
- // Album gain
- var albums = _libraryManager.GetItemList(new InternalItemsQuery
- {
- IncludeItemTypes = [BaseItemKind.MusicAlbum],
- Parent = library,
- Recursive = true
- });
+ double nextPercent = numComplete + 1;
+ nextPercent /= libraries.Length;
+ nextPercent -= percent;
+ // Split the progress for this single library into two halves: album gain and track gain.
+ // The first half will be for album gain, the second half for track gain.
+ nextPercent /= 2;
+ var albumComplete = 0;
foreach (var a in albums)
{
- if (a.NormalizationGain.HasValue || a.LUFS.HasValue)
+ if (!a.NormalizationGain.HasValue && !a.LUFS.HasValue)
{
- continue;
- }
+ // Album gain
+ var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList();
- // Skip albums that don't have multiple tracks, album gain is useless here
- var albumTracks = ((MusicAlbum)a).Tracks.Where(x => x.IsFileProtocol).ToList();
- if (albumTracks.Count <= 1)
- {
- continue;
+ // Skip albums that don't have multiple tracks, album gain is useless here
+ if (albumTracks.Count > 1)
+ {
+ _logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id);
+ var tempDir = _applicationPaths.TempDirectory;
+ Directory.CreateDirectory(tempDir);
+ var tempFile = Path.Join(tempDir, a.Id + ".concat");
+ var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal)));
+ await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ a.LUFS = await CalculateLUFSAsync(
+ string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
+ OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file
+ cancellationToken).ConfigureAwait(false);
+ toSaveDbItems.Add(a);
+ }
+ finally
+ {
+ try
+ {
+ File.Delete(tempFile);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to delete concat file: {FileName}.", tempFile);
+ }
+ }
+ }
}
- _logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id);
- var tempDir = _applicationPaths.TempDirectory;
- Directory.CreateDirectory(tempDir);
- var tempFile = Path.Join(tempDir, a.Id + ".concat");
- var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal)));
- await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false);
- try
- {
- a.LUFS = await CalculateLUFSAsync(
- string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
- OperatingSystem.IsWindows(), // Wait for process to exit on Windows before we try deleting the concat file
- cancellationToken).ConfigureAwait(false);
- }
- finally
+ if (Stopwatch.GetElapsedTime(startDbSaveInterval) > _dbSaveInterval)
{
- File.Delete(tempFile);
+ if (toSaveDbItems.Count > 1)
+ {
+ _itemRepository.SaveItems(toSaveDbItems, cancellationToken);
+ toSaveDbItems.Clear();
+ }
+
+ startDbSaveInterval = Stopwatch.GetTimestamp();
}
+
+ // Update sub-progress for album gain
+ albumComplete++;
+ double albumPercent = albumComplete;
+ albumPercent /= albums.Count;
+
+ progress.Report(100 * (percent + (albumPercent * nextPercent)));
}
- _itemRepository.SaveItems(albums, cancellationToken);
+ // Update progress to start at the track gain percent calculation
+ percent += nextPercent;
- // Track gain
- var tracks = _libraryManager.GetItemList(new InternalItemsQuery
+ if (toSaveDbItems.Count > 1)
{
- MediaTypes = [MediaType.Audio],
- IncludeItemTypes = [BaseItemKind.Audio],
- Parent = library,
- Recursive = true
- });
+ _itemRepository.SaveItems(toSaveDbItems, cancellationToken);
+ toSaveDbItems.Clear();
+ }
+
+ startDbSaveInterval = Stopwatch.GetTimestamp();
+ // Track gain
+ var tracks = _libraryManager.GetItemList(new InternalItemsQuery { MediaTypes = [MediaType.Audio], IncludeItemTypes = [BaseItemKind.Audio], Parent = library, Recursive = true });
+
+ var tracksComplete = 0;
foreach (var t in tracks)
{
- if (t.NormalizationGain.HasValue || t.LUFS.HasValue || !t.IsFileProtocol)
+ if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol)
{
- continue;
+ t.LUFS = await CalculateLUFSAsync(
+ string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
+ false,
+ cancellationToken).ConfigureAwait(false);
+ toSaveDbItems.Add(t);
+ }
+
+ if (Stopwatch.GetElapsedTime(startDbSaveInterval) > _dbSaveInterval)
+ {
+ if (toSaveDbItems.Count > 1)
+ {
+ _itemRepository.SaveItems(toSaveDbItems, cancellationToken);
+ toSaveDbItems.Clear();
+ }
+
+ startDbSaveInterval = Stopwatch.GetTimestamp();
}
- t.LUFS = await CalculateLUFSAsync(
- string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)),
- false,
- cancellationToken).ConfigureAwait(false);
+ // Update sub-progress for track gain
+ tracksComplete++;
+ double trackPercent = tracksComplete;
+ trackPercent /= tracks.Count;
+
+ progress.Report(100 * (percent + (trackPercent * nextPercent)));
+ }
+
+ if (toSaveDbItems.Count > 1)
+ {
+ _itemRepository.SaveItems(toSaveDbItems, cancellationToken);
}
- _itemRepository.SaveItems(tracks, cancellationToken);
+ // Update progress
+ numComplete++;
+ percent = numComplete;
+ percent /= libraries.Length;
+
+ progress.Report(100 * percent);
}
+
+ progress.Report(100.0);
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
- return
- [
- new TaskTriggerInfo
- {
- Type = TaskTriggerInfoType.IntervalTrigger,
- IntervalTicks = TimeSpan.FromHours(24).Ticks
- }
- ];
+ yield return new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromHours(24).Ticks
+ };
}
private async Task<float?> CalculateLUFSAsync(string inputArgs, bool waitForExit, CancellationToken cancellationToken)
@@ -181,9 +239,9 @@ public partial class AudioNormalizationTask : IScheduledTask
},
})
{
+ _logger.LogDebug("Starting ffmpeg with arguments: {Arguments}", args);
try
{
- _logger.LogDebug("Starting ffmpeg with arguments: {Arguments}", args);
process.Start();
}
catch (Exception ex)
@@ -192,16 +250,33 @@ public partial class AudioNormalizationTask : IScheduledTask
return null;
}
+ try
+ {
+ process.PriorityClass = ProcessPriorityClass.BelowNormal;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error setting ffmpeg process priority");
+ }
+
using var reader = process.StandardError;
float? lufs = null;
- await foreach (var line in reader.ReadAllLinesAsync(cancellationToken))
+ var foundLufs = false;
+ await foreach (var line in reader.ReadAllLinesAsync(cancellationToken).ConfigureAwait(false))
{
+ if (foundLufs)
+ {
+ continue;
+ }
+
Match match = LUFSRegex().Match(line);
- if (match.Success)
+ if (!match.Success)
{
- lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
- break;
+ continue;
}
+
+ lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
+ foundLufs = true;
}
if (lufs is null)
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
index 563e90fbe..f81309560 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
@@ -11,171 +11,157 @@ using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.MediaEncoding;
-using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Class ChapterImagesTask.
+/// </summary>
+public class ChapterImagesTask : IScheduledTask
{
+ private readonly ILogger<ChapterImagesTask> _logger;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IApplicationPaths _appPaths;
+ private readonly IChapterManager _chapterManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILocalizationManager _localization;
+
/// <summary>
- /// Class ChapterImagesTask.
+ /// Initializes a new instance of the <see cref="ChapterImagesTask" /> class.
/// </summary>
- public class ChapterImagesTask : IScheduledTask
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ /// <param name="chapterManager">Instance of the <see cref="IChapterManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ public ChapterImagesTask(
+ ILogger<ChapterImagesTask> logger,
+ ILibraryManager libraryManager,
+ IApplicationPaths appPaths,
+ IChapterManager chapterManager,
+ IFileSystem fileSystem,
+ ILocalizationManager localization)
{
- private readonly ILogger<ChapterImagesTask> _logger;
- private readonly ILibraryManager _libraryManager;
- private readonly IItemRepository _itemRepo;
- private readonly IApplicationPaths _appPaths;
- private readonly IEncodingManager _encodingManager;
- private readonly IFileSystem _fileSystem;
- private readonly ILocalizationManager _localization;
- private readonly IChapterRepository _chapterRepository;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="ChapterImagesTask" /> class.
- /// </summary>
- /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
- /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
- /// <param name="encodingManager">Instance of the <see cref="IEncodingManager"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- /// <param name="chapterRepository">Instance of the <see cref="IChapterRepository"/> interface.</param>
- public ChapterImagesTask(
- ILogger<ChapterImagesTask> logger,
- ILibraryManager libraryManager,
- IItemRepository itemRepo,
- IApplicationPaths appPaths,
- IEncodingManager encodingManager,
- IFileSystem fileSystem,
- ILocalizationManager localization,
- IChapterRepository chapterRepository)
- {
- _logger = logger;
- _libraryManager = libraryManager;
- _itemRepo = itemRepo;
- _appPaths = appPaths;
- _encodingManager = encodingManager;
- _fileSystem = fileSystem;
- _localization = localization;
- _chapterRepository = chapterRepository;
- }
+ _logger = logger;
+ _libraryManager = libraryManager;
+ _appPaths = appPaths;
+ _chapterManager = chapterManager;
+ _fileSystem = fileSystem;
+ _localization = localization;
+ }
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskRefreshChapterImages");
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskRefreshChapterImages");
- /// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TaskRefreshChapterImagesDescription");
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskRefreshChapterImagesDescription");
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
- /// <inheritdoc />
- public string Key => "RefreshChapterImages";
+ /// <inheritdoc />
+ public string Key => "RefreshChapterImages";
- /// <inheritdoc />
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ yield return new TaskTriggerInfo
{
- return
- [
- new TaskTriggerInfo
- {
- Type = TaskTriggerInfoType.DailyTrigger,
- TimeOfDayTicks = TimeSpan.FromHours(2).Ticks,
- MaxRuntimeTicks = TimeSpan.FromHours(4).Ticks
- }
- ];
- }
+ Type = TaskTriggerInfoType.DailyTrigger,
+ TimeOfDayTicks = TimeSpan.FromHours(2).Ticks,
+ MaxRuntimeTicks = TimeSpan.FromHours(4).Ticks
+ };
+ }
- /// <inheritdoc />
- public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ /// <inheritdoc />
+ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var videos = _libraryManager.GetItemList(new InternalItemsQuery
{
- var videos = _libraryManager.GetItemList(new InternalItemsQuery
+ MediaTypes = [MediaType.Video],
+ IsFolder = false,
+ Recursive = true,
+ DtoOptions = new DtoOptions(false)
{
- MediaTypes = [MediaType.Video],
- IsFolder = false,
- Recursive = true,
- DtoOptions = new DtoOptions(false)
- {
- EnableImages = false
- },
- SourceTypes = [SourceType.Library],
- IsVirtualItem = false
- })
- .OfType<Video>()
- .ToList();
+ EnableImages = false
+ },
+ SourceTypes = [SourceType.Library],
+ IsVirtualItem = false
+ })
+ .OfType<Video>()
+ .ToList();
- var numComplete = 0;
+ var numComplete = 0;
- var failHistoryPath = Path.Combine(_appPaths.CachePath, "chapter-failures.txt");
+ var failHistoryPath = Path.Combine(_appPaths.CachePath, "chapter-failures.txt");
- List<string> previouslyFailedImages;
+ List<string> previouslyFailedImages;
- if (File.Exists(failHistoryPath))
+ if (File.Exists(failHistoryPath))
+ {
+ try
{
- try
- {
- previouslyFailedImages = (await File.ReadAllTextAsync(failHistoryPath, cancellationToken).ConfigureAwait(false))
- .Split('|', StringSplitOptions.RemoveEmptyEntries)
- .ToList();
- }
- catch (IOException)
- {
- previouslyFailedImages = new List<string>();
- }
+ previouslyFailedImages = (await File.ReadAllTextAsync(failHistoryPath, cancellationToken).ConfigureAwait(false))
+ .Split('|', StringSplitOptions.RemoveEmptyEntries)
+ .ToList();
}
- else
+ catch (IOException)
{
- previouslyFailedImages = new List<string>();
+ previouslyFailedImages = [];
}
+ }
+ else
+ {
+ previouslyFailedImages = [];
+ }
- var directoryService = new DirectoryService(_fileSystem);
-
- foreach (var video in videos)
- {
- cancellationToken.ThrowIfCancellationRequested();
+ var directoryService = new DirectoryService(_fileSystem);
- var key = video.Path + video.DateModified.Ticks;
+ foreach (var video in videos)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
- var extract = !previouslyFailedImages.Contains(key, StringComparison.OrdinalIgnoreCase);
+ var key = video.Path + video.DateModified.Ticks;
- try
- {
- var chapters = _chapterRepository.GetChapters(video.Id);
+ var extract = !previouslyFailedImages.Contains(key, StringComparison.OrdinalIgnoreCase);
- var success = await _encodingManager.RefreshChapterImages(video, directoryService, chapters, extract, true, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var chapters = _chapterManager.GetChapters(video.Id);
- if (!success)
- {
- previouslyFailedImages.Add(key);
+ var success = await _chapterManager.RefreshChapterImages(video, directoryService, chapters, extract, true, cancellationToken).ConfigureAwait(false);
- var parentPath = Path.GetDirectoryName(failHistoryPath);
- if (parentPath is not null)
- {
- Directory.CreateDirectory(parentPath);
- }
+ if (!success)
+ {
+ previouslyFailedImages.Add(key);
- string text = string.Join('|', previouslyFailedImages);
- await File.WriteAllTextAsync(failHistoryPath, text, cancellationToken).ConfigureAwait(false);
+ var parentPath = Path.GetDirectoryName(failHistoryPath);
+ if (parentPath is not null)
+ {
+ Directory.CreateDirectory(parentPath);
}
- numComplete++;
- double percent = numComplete;
- percent /= videos.Count;
-
- progress.Report(100 * percent);
- }
- catch (ObjectDisposedException ex)
- {
- // TODO Investigate and properly fix.
- _logger.LogError(ex, "Object Disposed");
- break;
+ string text = string.Join('|', previouslyFailedImages);
+ await File.WriteAllTextAsync(failHistoryPath, text, cancellationToken).ConfigureAwait(false);
}
+
+ numComplete++;
+ double percent = numComplete;
+ percent /= videos.Count;
+
+ progress.Report(100 * percent);
+ }
+ catch (ObjectDisposedException ex)
+ {
+ // TODO Investigate and properly fix.
+ _logger.LogError(ex, "Object Disposed");
+ break;
}
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs
index fe1832165..1621bbaa1 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanActivityLogTask.cs
@@ -7,71 +7,70 @@ using MediaBrowser.Model.Activity;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
-namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Deletes old activity log entries.
+/// </summary>
+public class CleanActivityLogTask : IScheduledTask, IConfigurableScheduledTask
{
+ private readonly ILocalizationManager _localization;
+ private readonly IActivityManager _activityManager;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
/// <summary>
- /// Deletes old activity log entries.
+ /// Initializes a new instance of the <see cref="CleanActivityLogTask"/> class.
/// </summary>
- public class CleanActivityLogTask : IScheduledTask, IConfigurableScheduledTask
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public CleanActivityLogTask(
+ ILocalizationManager localization,
+ IActivityManager activityManager,
+ IServerConfigurationManager serverConfigurationManager)
{
- private readonly ILocalizationManager _localization;
- private readonly IActivityManager _activityManager;
- private readonly IServerConfigurationManager _serverConfigurationManager;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="CleanActivityLogTask"/> class.
- /// </summary>
- /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- /// <param name="activityManager">Instance of the <see cref="IActivityManager"/> interface.</param>
- /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
- public CleanActivityLogTask(
- ILocalizationManager localization,
- IActivityManager activityManager,
- IServerConfigurationManager serverConfigurationManager)
- {
- _localization = localization;
- _activityManager = activityManager;
- _serverConfigurationManager = serverConfigurationManager;
- }
+ _localization = localization;
+ _activityManager = activityManager;
+ _serverConfigurationManager = serverConfigurationManager;
+ }
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskCleanActivityLog");
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskCleanActivityLog");
- /// <inheritdoc />
- public string Key => "CleanActivityLog";
+ /// <inheritdoc />
+ public string Key => "CleanActivityLog";
- /// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TaskCleanActivityLogDescription");
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskCleanActivityLogDescription");
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
- /// <inheritdoc />
- public bool IsHidden => false;
+ /// <inheritdoc />
+ public bool IsHidden => false;
- /// <inheritdoc />
- public bool IsEnabled => true;
+ /// <inheritdoc />
+ public bool IsEnabled => true;
- /// <inheritdoc />
- public bool IsLogged => true;
+ /// <inheritdoc />
+ public bool IsLogged => true;
- /// <inheritdoc />
- public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ /// <inheritdoc />
+ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var retentionDays = _serverConfigurationManager.Configuration.ActivityLogRetentionDays;
+ if (!retentionDays.HasValue || retentionDays < 0)
{
- var retentionDays = _serverConfigurationManager.Configuration.ActivityLogRetentionDays;
- if (!retentionDays.HasValue || retentionDays < 0)
- {
- throw new InvalidOperationException($"Activity Log Retention days must be at least 0. Currently: {retentionDays}");
- }
-
- var startDate = DateTime.UtcNow.AddDays(-retentionDays.Value);
- return _activityManager.CleanAsync(startDate);
+ throw new InvalidOperationException($"Activity Log Retention days must be at least 0. Currently: {retentionDays}");
}
- /// <inheritdoc />
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
- {
- return [];
- }
+ var startDate = DateTime.UtcNow.AddDays(-retentionDays.Value);
+ return _activityManager.CleanAsync(startDate);
+ }
+
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return [];
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs
index 8901390aa..7f68f7701 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs
@@ -27,7 +27,6 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
private readonly IPlaylistManager _playlistManager;
private readonly ILogger<CleanupCollectionAndPlaylistPathsTask> _logger;
private readonly IProviderManager _providerManager;
- private readonly IFileSystem _fileSystem;
/// <summary>
/// Initializes a new instance of the <see cref="CleanupCollectionAndPlaylistPathsTask"/> class.
@@ -37,21 +36,18 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
/// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
public CleanupCollectionAndPlaylistPathsTask(
ILocalizationManager localization,
ICollectionManager collectionManager,
IPlaylistManager playlistManager,
ILogger<CleanupCollectionAndPlaylistPathsTask> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem)
+ IProviderManager providerManager)
{
_localization = localization;
_collectionManager = collectionManager;
_playlistManager = playlistManager;
_logger = logger;
_providerManager = providerManager;
- _fileSystem = fileSystem;
}
/// <inheritdoc />
@@ -135,6 +131,9 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
- return [new TaskTriggerInfo() { Type = TaskTriggerInfoType.StartupTrigger }];
+ yield return new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfoType.StartupTrigger,
+ };
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupUserDataTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupUserDataTask.cs
new file mode 100644
index 000000000..4156050eb
--- /dev/null
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupUserDataTask.cs
@@ -0,0 +1,77 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Task to clean up any detached userdata from the database.
+/// </summary>
+public class CleanupUserDataTask : IScheduledTask
+{
+ private readonly ILocalizationManager _localization;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly ILogger<CleanupUserDataTask> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CleanupUserDataTask"/> class.
+ /// </summary>
+ /// <param name="localization">The localisation Provider.</param>
+ /// <param name="dbProvider">The DB context factory.</param>
+ /// <param name="logger">A logger.</param>
+ public CleanupUserDataTask(ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbProvider, ILogger<CleanupUserDataTask> logger)
+ {
+ _localization = localization;
+ _dbProvider = dbProvider;
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("CleanupUserDataTask");
+
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("CleanupUserDataTaskDescription");
+
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+
+ /// <inheritdoc />
+ public string Key => nameof(CleanupUserDataTask);
+
+ /// <inheritdoc/>
+ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ const int LimitDays = 90;
+ var userDataDate = DateTime.UtcNow.AddDays(LimitDays * -1);
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var detachedUserData = dbContext.UserData.Where(e => e.ItemId == BaseItemRepository.PlaceholderId);
+ _logger.LogInformation("There are {NoDetached} detached UserData entries.", detachedUserData.Count());
+
+ detachedUserData = detachedUserData.Where(e => e.RetentionDate < userDataDate);
+
+ _logger.LogInformation("{NoDetached} are older then {Limit} days.", detachedUserData.Count(), LimitDays);
+
+ await detachedUserData.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ progress.Report(100);
+ }
+
+ /// <inheritdoc/>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ yield break;
+ }
+}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs
index ff295d9b7..0e77f0102 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs
@@ -11,134 +11,133 @@ using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Deletes old cache files.
+/// </summary>
+public class DeleteCacheFileTask : IScheduledTask, IConfigurableScheduledTask
{
/// <summary>
- /// Deletes old cache files.
+ /// Gets or sets the application paths.
+ /// </summary>
+ /// <value>The application paths.</value>
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly ILogger<DeleteCacheFileTask> _logger;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILocalizationManager _localization;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DeleteCacheFileTask" /> class.
/// </summary>
- public class DeleteCacheFileTask : IScheduledTask, IConfigurableScheduledTask
+ /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ public DeleteCacheFileTask(
+ IApplicationPaths appPaths,
+ ILogger<DeleteCacheFileTask> logger,
+ IFileSystem fileSystem,
+ ILocalizationManager localization)
{
- /// <summary>
- /// Gets or sets the application paths.
- /// </summary>
- /// <value>The application paths.</value>
- private readonly IApplicationPaths _applicationPaths;
- private readonly ILogger<DeleteCacheFileTask> _logger;
- private readonly IFileSystem _fileSystem;
- private readonly ILocalizationManager _localization;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="DeleteCacheFileTask" /> class.
- /// </summary>
- /// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
- /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- public DeleteCacheFileTask(
- IApplicationPaths appPaths,
- ILogger<DeleteCacheFileTask> logger,
- IFileSystem fileSystem,
- ILocalizationManager localization)
- {
- _applicationPaths = appPaths;
- _logger = logger;
- _fileSystem = fileSystem;
- _localization = localization;
- }
+ _applicationPaths = appPaths;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _localization = localization;
+ }
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskCleanCache");
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskCleanCache");
- /// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TaskCleanCacheDescription");
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskCleanCacheDescription");
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
- /// <inheritdoc />
- public string Key => "DeleteCacheFiles";
+ /// <inheritdoc />
+ public string Key => "DeleteCacheFiles";
- /// <inheritdoc />
- public bool IsHidden => false;
+ /// <inheritdoc />
+ public bool IsHidden => false;
- /// <inheritdoc />
- public bool IsEnabled => true;
+ /// <inheritdoc />
+ public bool IsEnabled => true;
- /// <inheritdoc />
- public bool IsLogged => true;
+ /// <inheritdoc />
+ public bool IsLogged => true;
- /// <inheritdoc />
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ yield return new TaskTriggerInfo
{
- return
- [
- // Every so often
- new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks }
- ];
- }
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromHours(24).Ticks
+ };
+ }
- /// <inheritdoc />
- public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ /// <inheritdoc />
+ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var minDateModified = DateTime.UtcNow.AddDays(-30);
+
+ try
+ {
+ DeleteCacheFilesFromDirectory(_applicationPaths.CachePath, minDateModified, progress, cancellationToken);
+ }
+ catch (DirectoryNotFoundException)
{
- var minDateModified = DateTime.UtcNow.AddDays(-30);
-
- try
- {
- DeleteCacheFilesFromDirectory(_applicationPaths.CachePath, minDateModified, progress, cancellationToken);
- }
- catch (DirectoryNotFoundException)
- {
- // No biggie here. Nothing to delete
- }
-
- progress.Report(90);
-
- minDateModified = DateTime.UtcNow.AddDays(-1);
-
- try
- {
- DeleteCacheFilesFromDirectory(_applicationPaths.TempDirectory, minDateModified, progress, cancellationToken);
- }
- catch (DirectoryNotFoundException)
- {
- // No biggie here. Nothing to delete
- }
-
- return Task.CompletedTask;
+ // No biggie here. Nothing to delete
}
- /// <summary>
- /// Deletes the cache files from directory with a last write time less than a given date.
- /// </summary>
- /// <param name="directory">The directory.</param>
- /// <param name="minDateModified">The min date modified.</param>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The task cancellation token.</param>
- private void DeleteCacheFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
+ progress.Report(90);
+
+ minDateModified = DateTime.UtcNow.AddDays(-1);
+
+ try
{
- var filesToDelete = _fileSystem.GetFiles(directory, true)
- .Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
- .ToList();
+ DeleteCacheFilesFromDirectory(_applicationPaths.TempDirectory, minDateModified, progress, cancellationToken);
+ }
+ catch (DirectoryNotFoundException)
+ {
+ // No biggie here. Nothing to delete
+ }
- var index = 0;
+ return Task.CompletedTask;
+ }
- foreach (var file in filesToDelete)
- {
- double percent = index;
- percent /= filesToDelete.Count;
+ /// <summary>
+ /// Deletes the cache files from directory with a last write time less than a given date.
+ /// </summary>
+ /// <param name="directory">The directory.</param>
+ /// <param name="minDateModified">The min date modified.</param>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The task cancellation token.</param>
+ private void DeleteCacheFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var filesToDelete = _fileSystem.GetFiles(directory, true)
+ .Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
+ .ToList();
- progress.Report(100 * percent);
+ var index = 0;
- cancellationToken.ThrowIfCancellationRequested();
+ foreach (var file in filesToDelete)
+ {
+ double percent = index;
+ percent /= filesToDelete.Count;
- FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
+ progress.Report(100 * percent);
- index++;
- }
+ cancellationToken.ThrowIfCancellationRequested();
- FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
+ FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
- progress.Report(100);
+ index++;
}
+
+ FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
+
+ progress.Report(100);
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs
index a091c2bd9..699529527 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteLogFileTask.cs
@@ -9,93 +9,93 @@ using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
-namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Deletes old log files.
+/// </summary>
+public class DeleteLogFileTask : IScheduledTask, IConfigurableScheduledTask
{
+ private readonly IConfigurationManager _configurationManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILocalizationManager _localization;
+
/// <summary>
- /// Deletes old log files.
+ /// Initializes a new instance of the <see cref="DeleteLogFileTask" /> class.
/// </summary>
- public class DeleteLogFileTask : IScheduledTask, IConfigurableScheduledTask
+ /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ public DeleteLogFileTask(IConfigurationManager configurationManager, IFileSystem fileSystem, ILocalizationManager localization)
{
- private readonly IConfigurationManager _configurationManager;
- private readonly IFileSystem _fileSystem;
- private readonly ILocalizationManager _localization;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="DeleteLogFileTask" /> class.
- /// </summary>
- /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- public DeleteLogFileTask(IConfigurationManager configurationManager, IFileSystem fileSystem, ILocalizationManager localization)
- {
- _configurationManager = configurationManager;
- _fileSystem = fileSystem;
- _localization = localization;
- }
+ _configurationManager = configurationManager;
+ _fileSystem = fileSystem;
+ _localization = localization;
+ }
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskCleanLogs");
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskCleanLogs");
- /// <inheritdoc />
- public string Description => string.Format(
- CultureInfo.InvariantCulture,
- _localization.GetLocalizedString("TaskCleanLogsDescription"),
- _configurationManager.CommonConfiguration.LogFileRetentionDays);
+ /// <inheritdoc />
+ public string Description => string.Format(
+ CultureInfo.InvariantCulture,
+ _localization.GetLocalizedString("TaskCleanLogsDescription"),
+ _configurationManager.CommonConfiguration.LogFileRetentionDays);
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
- /// <inheritdoc />
- public string Key => "CleanLogFiles";
+ /// <inheritdoc />
+ public string Key => "CleanLogFiles";
- /// <inheritdoc />
- public bool IsHidden => false;
+ /// <inheritdoc />
+ public bool IsHidden => false;
- /// <inheritdoc />
- public bool IsEnabled => true;
+ /// <inheritdoc />
+ public bool IsEnabled => true;
- /// <inheritdoc />
- public bool IsLogged => true;
+ /// <inheritdoc />
+ public bool IsLogged => true;
- /// <inheritdoc />
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ yield return new TaskTriggerInfo
{
- return
- [
- new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks }
- ];
- }
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromHours(24).Ticks
+ };
+ }
- /// <inheritdoc />
- public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
- {
- // Delete log files more than n days old
- var minDateModified = DateTime.UtcNow.AddDays(-_configurationManager.CommonConfiguration.LogFileRetentionDays);
+ /// <inheritdoc />
+ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ // Delete log files more than n days old
+ var minDateModified = DateTime.UtcNow.AddDays(-_configurationManager.CommonConfiguration.LogFileRetentionDays);
- // Only delete files that serilog doesn't manage (anything that doesn't start with 'log_'
- var filesToDelete = _fileSystem.GetFiles(_configurationManager.CommonApplicationPaths.LogDirectoryPath, true)
- .Where(f => !f.Name.StartsWith("log_", StringComparison.Ordinal)
- && _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
- .ToList();
+ // Only delete files that serilog doesn't manage (anything that doesn't start with 'log_'
+ var filesToDelete = _fileSystem.GetFiles(_configurationManager.CommonApplicationPaths.LogDirectoryPath, true)
+ .Where(f => !f.Name.StartsWith("log_", StringComparison.Ordinal)
+ && _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
+ .ToList();
- var index = 0;
+ var index = 0;
- foreach (var file in filesToDelete)
- {
- double percent = index / (double)filesToDelete.Count;
+ foreach (var file in filesToDelete)
+ {
+ double percent = index / (double)filesToDelete.Count;
- progress.Report(100 * percent);
+ progress.Report(100 * percent);
- cancellationToken.ThrowIfCancellationRequested();
+ cancellationToken.ThrowIfCancellationRequested();
- _fileSystem.DeleteFile(file.FullName);
+ _fileSystem.DeleteFile(file.FullName);
- index++;
- }
+ index++;
+ }
- progress.Report(100);
+ progress.Report(100);
- return Task.CompletedTask;
- }
+ return Task.CompletedTask;
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
index d0896cc81..9cc2cc512 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
@@ -10,118 +10,115 @@ using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Deletes all transcoding temp files.
+/// </summary>
+public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTask
{
+ private readonly ILogger<DeleteTranscodeFileTask> _logger;
+ private readonly IConfigurationManager _configurationManager;
+ private readonly IFileSystem _fileSystem;
+ private readonly ILocalizationManager _localization;
+
/// <summary>
- /// Deletes all transcoding temp files.
+ /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
/// </summary>
- public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTask
+ /// <param name="logger">Instance of the <see cref="ILogger{DeleteTranscodeFileTask}"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ public DeleteTranscodeFileTask(
+ ILogger<DeleteTranscodeFileTask> logger,
+ IFileSystem fileSystem,
+ IConfigurationManager configurationManager,
+ ILocalizationManager localization)
{
- private readonly ILogger<DeleteTranscodeFileTask> _logger;
- private readonly IConfigurationManager _configurationManager;
- private readonly IFileSystem _fileSystem;
- private readonly ILocalizationManager _localization;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
- /// </summary>
- /// <param name="logger">Instance of the <see cref="ILogger{DeleteTranscodeFileTask}"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
- /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- public DeleteTranscodeFileTask(
- ILogger<DeleteTranscodeFileTask> logger,
- IFileSystem fileSystem,
- IConfigurationManager configurationManager,
- ILocalizationManager localization)
- {
- _logger = logger;
- _fileSystem = fileSystem;
- _configurationManager = configurationManager;
- _localization = localization;
- }
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _configurationManager = configurationManager;
+ _localization = localization;
+ }
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskCleanTranscode");
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskCleanTranscode");
- /// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription");
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription");
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
- /// <inheritdoc />
- public string Key => "DeleteTranscodeFiles";
+ /// <inheritdoc />
+ public string Key => "DeleteTranscodeFiles";
- /// <inheritdoc />
- public bool IsHidden => false;
+ /// <inheritdoc />
+ public bool IsHidden => false;
- /// <inheritdoc />
- public bool IsEnabled => true;
+ /// <inheritdoc />
+ public bool IsEnabled => true;
- /// <inheritdoc />
- public bool IsLogged => true;
+ /// <inheritdoc />
+ public bool IsLogged => true;
- /// <inheritdoc />
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ yield return new TaskTriggerInfo
{
- return
- [
- new TaskTriggerInfo
- {
- Type = TaskTriggerInfoType.StartupTrigger
- },
- new TaskTriggerInfo
- {
- Type = TaskTriggerInfoType.IntervalTrigger,
- IntervalTicks = TimeSpan.FromHours(24).Ticks
- }
- ];
- }
+ Type = TaskTriggerInfoType.StartupTrigger
+ };
- /// <inheritdoc />
- public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ yield return new TaskTriggerInfo
{
- var minDateModified = DateTime.UtcNow.AddDays(-1);
- progress.Report(50);
-
- DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromHours(24).Ticks
+ };
+ }
- return Task.CompletedTask;
- }
+ /// <inheritdoc />
+ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var minDateModified = DateTime.UtcNow.AddDays(-1);
+ progress.Report(50);
- /// <summary>
- /// Deletes the transcoded temp files from directory with a last write time less than a given date.
- /// </summary>
- /// <param name="directory">The directory.</param>
- /// <param name="minDateModified">The min date modified.</param>
- /// <param name="progress">The progress.</param>
- /// <param name="cancellationToken">The task cancellation token.</param>
- private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
- {
- var filesToDelete = _fileSystem.GetFiles(directory, true)
- .Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
- .ToList();
+ DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
- var index = 0;
+ return Task.CompletedTask;
+ }
- foreach (var file in filesToDelete)
- {
- double percent = index;
- percent /= filesToDelete.Count;
+ /// <summary>
+ /// Deletes the transcoded temp files from directory with a last write time less than a given date.
+ /// </summary>
+ /// <param name="directory">The directory.</param>
+ /// <param name="minDateModified">The min date modified.</param>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The task cancellation token.</param>
+ private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var filesToDelete = _fileSystem.GetFiles(directory, true)
+ .Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
+ .ToList();
- progress.Report(100 * percent);
+ var index = 0;
- cancellationToken.ThrowIfCancellationRequested();
+ foreach (var file in filesToDelete)
+ {
+ double percent = index;
+ percent /= filesToDelete.Count;
- FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
+ progress.Report(100 * percent);
- index++;
- }
+ cancellationToken.ThrowIfCancellationRequested();
- FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
+ FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
- progress.Report(100);
+ index++;
}
+
+ FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
+
+ progress.Report(100);
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
index de1e60d30..51920c5b1 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
@@ -4,10 +4,10 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
-using MediaBrowser.Controller;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
@@ -62,11 +62,11 @@ public class MediaSegmentExtractionTask : IScheduledTask
var query = new InternalItemsQuery
{
- MediaTypes = new[] { MediaType.Video, MediaType.Audio },
+ MediaTypes = [MediaType.Video, MediaType.Audio],
IsVirtualItem = false,
IncludeItemTypes = _itemTypes,
DtoOptions = new DtoOptions(true),
- SourceTypes = new[] { SourceType.Library },
+ SourceTypes = [SourceType.Library],
Recursive = true,
Limit = pagesize
};
@@ -91,7 +91,8 @@ public class MediaSegmentExtractionTask : IScheduledTask
// Only local files supported
if (item.IsFileProtocol && File.Exists(item.Path))
{
- await _mediaSegmentManager.RunSegmentPluginProviders(item, false, cancellationToken).ConfigureAwait(false);
+ var libraryOptions = _libraryManager.GetLibraryOptions(item);
+ await _mediaSegmentManager.RunSegmentPluginProviders(item, libraryOptions, false, cancellationToken).ConfigureAwait(false);
}
// Update progress
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
index 4d3a04377..92d7a3907 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs
@@ -5,84 +5,78 @@ using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
-using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Optimizes Jellyfin's database by issuing a VACUUM command.
+/// </summary>
+public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
{
+ private readonly ILogger<OptimizeDatabaseTask> _logger;
+ private readonly ILocalizationManager _localization;
+ private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
+
/// <summary>
- /// Optimizes Jellyfin's database by issuing a VACUUM command.
+ /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
/// </summary>
- public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ /// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
+ public OptimizeDatabaseTask(
+ ILogger<OptimizeDatabaseTask> logger,
+ ILocalizationManager localization,
+ IJellyfinDatabaseProvider jellyfinDatabaseProvider)
{
- private readonly ILogger<OptimizeDatabaseTask> _logger;
- private readonly ILocalizationManager _localization;
- private readonly IDbContextFactory<JellyfinDbContext> _provider;
- private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
+ _logger = logger;
+ _localization = localization;
+ _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+ }
- /// <summary>
- /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
- /// </summary>
- /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
- /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- /// <param name="provider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
- /// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
- public OptimizeDatabaseTask(
- ILogger<OptimizeDatabaseTask> logger,
- ILocalizationManager localization,
- IDbContextFactory<JellyfinDbContext> provider,
- IJellyfinDatabaseProvider jellyfinDatabaseProvider)
- {
- _logger = logger;
- _localization = localization;
- _provider = provider;
- _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
- }
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskOptimizeDatabase");
+
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskOptimizeDatabaseDescription");
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskOptimizeDatabase");
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
- /// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TaskOptimizeDatabaseDescription");
+ /// <inheritdoc />
+ public string Key => "OptimizeDatabaseTask";
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+ /// <inheritdoc />
+ public bool IsHidden => false;
- /// <inheritdoc />
- public string Key => "OptimizeDatabaseTask";
+ /// <inheritdoc />
+ public bool IsEnabled => true;
- /// <inheritdoc />
- public bool IsHidden => false;
+ /// <inheritdoc />
+ public bool IsLogged => true;
- /// <inheritdoc />
- public bool IsEnabled => true;
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ yield return new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromHours(6).Ticks
+ };
+ }
- /// <inheritdoc />
- public bool IsLogged => true;
+ /// <inheritdoc />
+ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
- /// <inheritdoc />
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ try
{
- return
- [
- // Every so often
- new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks }
- ];
+ await _jellyfinDatabaseProvider.RunScheduledOptimisation(cancellationToken).ConfigureAwait(false);
}
-
- /// <inheritdoc />
- public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ catch (Exception e)
{
- _logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
-
- try
- {
- await _jellyfinDatabaseProvider.RunScheduledOptimisation(cancellationToken).ConfigureAwait(false);
- }
- catch (Exception e)
- {
- _logger.LogError(e, "Error while optimizing jellyfin.db");
- }
+ _logger.LogError(e, "Error while optimizing jellyfin.db");
}
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
index 2907f18b5..6e4e5c780 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs
@@ -1,73 +1,129 @@
using System;
+using System.Buffers;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
+using Microsoft.EntityFrameworkCore;
-namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Class PeopleValidationTask.
+/// </summary>
+public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
{
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILocalizationManager _localization;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+
/// <summary>
- /// Class PeopleValidationTask.
+ /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
/// </summary>
- public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ /// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
+ public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory)
{
- private readonly ILibraryManager _libraryManager;
- private readonly ILocalizationManager _localization;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
- /// </summary>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization)
- {
- _libraryManager = libraryManager;
- _localization = localization;
- }
+ _libraryManager = libraryManager;
+ _localization = localization;
+ _dbContextFactory = dbContextFactory;
+ }
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskRefreshPeople");
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskRefreshPeople");
- /// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TaskRefreshPeopleDescription");
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskRefreshPeopleDescription");
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
- /// <inheritdoc />
- public string Key => "RefreshPeople";
+ /// <inheritdoc />
+ public string Key => "RefreshPeople";
- /// <inheritdoc />
- public bool IsHidden => false;
+ /// <inheritdoc />
+ public bool IsHidden => false;
- /// <inheritdoc />
- public bool IsEnabled => true;
+ /// <inheritdoc />
+ public bool IsEnabled => true;
+
+ /// <inheritdoc />
+ public bool IsLogged => true;
+
+ /// <summary>
+ /// Creates the triggers that define when the task will run.
+ /// </summary>
+ /// <returns>An <see cref="IEnumerable{TaskTriggerInfo}"/> containing the default trigger infos for this task.</returns>
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ yield return new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromDays(7).Ticks
+ };
+ }
- /// <inheritdoc />
- public bool IsLogged => true;
+ /// <inheritdoc />
+ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
+ await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false);
- /// <summary>
- /// Creates the triggers that define when the task will run.
- /// </summary>
- /// <returns>An <see cref="IEnumerable{TaskTriggerInfo}"/> containing the default trigger infos for this task.</returns>
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
+ var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
{
- return new[]
+ var dupQuery = context.Peoples
+ .GroupBy(e => new { e.Name, e.PersonType })
+ .Where(e => e.Count() > 1)
+ .Select(e => e.Select(f => f.Id).ToArray());
+
+ var total = dupQuery.Count();
+
+ const int PartitionSize = 100;
+ var iterator = 0;
+ int itemCounter;
+ var buffer = ArrayPool<Guid[]>.Shared.Rent(PartitionSize)!;
+ try
{
- new TaskTriggerInfo
+ do
{
- Type = TaskTriggerInfoType.IntervalTrigger,
- IntervalTicks = TimeSpan.FromDays(7).Ticks
- }
- };
- }
+ itemCounter = 0;
+ await foreach (var item in dupQuery
+ .Take(PartitionSize)
+ .AsAsyncEnumerable()
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ buffer[itemCounter++] = item;
+ }
- /// <inheritdoc />
- public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
- {
- return _libraryManager.ValidatePeopleAsync(progress, cancellationToken);
+ for (int i = 0; i < itemCounter; i++)
+ {
+ var item = buffer[i];
+ var reference = item[0];
+ var dups = item[1..];
+ await context.PeopleBaseItemMap.WhereOneOrMany(dups, e => e.PeopleId)
+ .ExecuteUpdateAsync(e => e.SetProperty(f => f.PeopleId, reference), cancellationToken)
+ .ConfigureAwait(false);
+ await context.Peoples.Where(e => dups.Contains(e.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ subProgress.Report(100f / total * ((iterator * PartitionSize) + i));
+ }
+
+ iterator++;
+ } while (itemCounter == PartitionSize && !cancellationToken.IsCancellationRequested);
+ }
+ finally
+ {
+ ArrayPool<Guid[]>.Shared.Return(buffer);
+ }
+
+ subProgress.Report(100);
}
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs
index b74f4d1b2..31153af20 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PluginUpdateTask.cs
@@ -10,111 +10,115 @@ using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Plugin Update Task.
+/// </summary>
+public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask
{
+ private readonly ILogger<PluginUpdateTask> _logger;
+
+ private readonly IInstallationManager _installationManager;
+ private readonly ILocalizationManager _localization;
+
/// <summary>
- /// Plugin Update Task.
+ /// Initializes a new instance of the <see cref="PluginUpdateTask" /> class.
/// </summary>
- public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ public PluginUpdateTask(ILogger<PluginUpdateTask> logger, IInstallationManager installationManager, ILocalizationManager localization)
{
- private readonly ILogger<PluginUpdateTask> _logger;
-
- private readonly IInstallationManager _installationManager;
- private readonly ILocalizationManager _localization;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="PluginUpdateTask" /> class.
- /// </summary>
- /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
- /// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
- /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- public PluginUpdateTask(ILogger<PluginUpdateTask> logger, IInstallationManager installationManager, ILocalizationManager localization)
- {
- _logger = logger;
- _installationManager = installationManager;
- _localization = localization;
- }
+ _logger = logger;
+ _installationManager = installationManager;
+ _localization = localization;
+ }
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskUpdatePlugins");
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskUpdatePlugins");
- /// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TaskUpdatePluginsDescription");
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskUpdatePluginsDescription");
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksApplicationCategory");
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksApplicationCategory");
- /// <inheritdoc />
- public string Key => "PluginUpdates";
+ /// <inheritdoc />
+ public string Key => "PluginUpdates";
- /// <inheritdoc />
- public bool IsHidden => false;
+ /// <inheritdoc />
+ public bool IsHidden => false;
- /// <inheritdoc />
- public bool IsEnabled => true;
+ /// <inheritdoc />
+ public bool IsEnabled => true;
- /// <inheritdoc />
- public bool IsLogged => true;
+ /// <inheritdoc />
+ public bool IsLogged => true;
- /// <inheritdoc />
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ yield return new TaskTriggerInfo
{
- // At startup
- yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.StartupTrigger };
-
- // Every so often
- yield return new TaskTriggerInfo { Type = TaskTriggerInfoType.IntervalTrigger, IntervalTicks = TimeSpan.FromHours(24).Ticks };
- }
+ Type = TaskTriggerInfoType.StartupTrigger
+ };
- /// <inheritdoc />
- public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ yield return new TaskTriggerInfo
{
- progress.Report(0);
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromHours(24).Ticks
+ };
+ }
- var packageFetchTask = _installationManager.GetAvailablePluginUpdates(cancellationToken);
- var packagesToInstall = (await packageFetchTask.ConfigureAwait(false)).ToList();
+ /// <inheritdoc />
+ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ progress.Report(0);
- progress.Report(10);
+ var packageFetchTask = _installationManager.GetAvailablePluginUpdates(cancellationToken);
+ var packagesToInstall = (await packageFetchTask.ConfigureAwait(false)).ToList();
- var numComplete = 0;
+ progress.Report(10);
- foreach (var package in packagesToInstall)
- {
- cancellationToken.ThrowIfCancellationRequested();
+ var numComplete = 0;
- try
- {
- await _installationManager.InstallPackage(package, cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException)
- {
- // InstallPackage has its own inner cancellation token, so only throw this if it's ours
- if (cancellationToken.IsCancellationRequested)
- {
- throw;
- }
- }
- catch (HttpRequestException ex)
- {
- _logger.LogError(ex, "Error downloading {0}", package.Name);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error updating {0}", package.Name);
- }
- catch (InvalidDataException ex)
- {
- _logger.LogError(ex, "Error updating {0}", package.Name);
- }
+ foreach (var package in packagesToInstall)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
- // Update progress
- lock (progress)
+ try
+ {
+ await _installationManager.InstallPackage(package, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // InstallPackage has its own inner cancellation token, so only throw this if it's ours
+ if (cancellationToken.IsCancellationRequested)
{
- progress.Report((90.0 * ++numComplete / packagesToInstall.Count) + 10);
+ throw;
}
}
+ catch (HttpRequestException ex)
+ {
+ _logger.LogError(ex, "Error downloading {Name}", package.Name);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error updating {Name}", package.Name);
+ }
+ catch (InvalidDataException ex)
+ {
+ _logger.LogError(ex, "Error updating {Name}", package.Name);
+ }
- progress.Report(100);
+ // Update progress
+ lock (progress)
+ {
+ progress.Report((90.0 * ++numComplete / packagesToInstall.Count) + 10);
+ }
}
+
+ progress.Report(100);
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs
index 172448dde..1865189d0 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/RefreshMediaLibraryTask.cs
@@ -7,60 +7,59 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
-namespace Emby.Server.Implementations.ScheduledTasks.Tasks
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// Class RefreshMediaLibraryTask.
+/// </summary>
+public class RefreshMediaLibraryTask : IScheduledTask
{
/// <summary>
- /// Class RefreshMediaLibraryTask.
+ /// The _library manager.
/// </summary>
- public class RefreshMediaLibraryTask : IScheduledTask
- {
- /// <summary>
- /// The _library manager.
- /// </summary>
- private readonly ILibraryManager _libraryManager;
- private readonly ILocalizationManager _localization;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILocalizationManager _localization;
- /// <summary>
- /// Initializes a new instance of the <see cref="RefreshMediaLibraryTask" /> class.
- /// </summary>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- public RefreshMediaLibraryTask(ILibraryManager libraryManager, ILocalizationManager localization)
- {
- _libraryManager = libraryManager;
- _localization = localization;
- }
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshMediaLibraryTask" /> class.
+ /// </summary>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ public RefreshMediaLibraryTask(ILibraryManager libraryManager, ILocalizationManager localization)
+ {
+ _libraryManager = libraryManager;
+ _localization = localization;
+ }
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskRefreshLibrary");
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskRefreshLibrary");
- /// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TaskRefreshLibraryDescription");
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskRefreshLibraryDescription");
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
- /// <inheritdoc />
- public string Key => "RefreshLibrary";
+ /// <inheritdoc />
+ public string Key => "RefreshLibrary";
- /// <inheritdoc />
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ yield return new TaskTriggerInfo
{
- yield return new TaskTriggerInfo
- {
- Type = TaskTriggerInfoType.IntervalTrigger,
- IntervalTicks = TimeSpan.FromHours(12).Ticks
- };
- }
+ Type = TaskTriggerInfoType.IntervalTrigger,
+ IntervalTicks = TimeSpan.FromHours(12).Ticks
+ };
+ }
- /// <inheritdoc />
- public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
- {
- cancellationToken.ThrowIfCancellationRequested();
+ /// <inheritdoc />
+ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
- progress.Report(0);
+ progress.Report(0);
- return ((LibraryManager)_libraryManager).ValidateMediaLibraryInternal(progress, cancellationToken);
- }
+ await ((LibraryManager)_libraryManager).ValidateMediaLibraryInternal(progress, cancellationToken).ConfigureAwait(false);
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs
index 6d2a74da4..9abcd9c7b 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/DailyTrigger.cs
@@ -3,85 +3,84 @@ using System.Threading;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks.Triggers
+namespace Emby.Server.Implementations.ScheduledTasks.Triggers;
+
+/// <summary>
+/// Represents a task trigger that fires everyday.
+/// </summary>
+public sealed class DailyTrigger : ITaskTrigger, IDisposable
{
+ private readonly TimeSpan _timeOfDay;
+ private Timer? _timer;
+ private bool _disposed;
+
/// <summary>
- /// Represents a task trigger that fires everyday.
+ /// Initializes a new instance of the <see cref="DailyTrigger"/> class.
/// </summary>
- public sealed class DailyTrigger : ITaskTrigger, IDisposable
+ /// <param name="timeOfDay">The time of day to trigger the task to run.</param>
+ /// <param name="taskOptions">The options of this task.</param>
+ public DailyTrigger(TimeSpan timeOfDay, TaskOptions taskOptions)
{
- private readonly TimeSpan _timeOfDay;
- private Timer? _timer;
- private bool _disposed = false;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="DailyTrigger"/> class.
- /// </summary>
- /// <param name="timeofDay">The time of day to trigger the task to run.</param>
- /// <param name="taskOptions">The options of this task.</param>
- public DailyTrigger(TimeSpan timeofDay, TaskOptions taskOptions)
- {
- _timeOfDay = timeofDay;
- TaskOptions = taskOptions;
- }
+ _timeOfDay = timeOfDay;
+ TaskOptions = taskOptions;
+ }
- /// <inheritdoc />
- public event EventHandler<EventArgs>? Triggered;
+ /// <inheritdoc />
+ public event EventHandler<EventArgs>? Triggered;
- /// <inheritdoc />
- public TaskOptions TaskOptions { get; }
+ /// <inheritdoc />
+ public TaskOptions TaskOptions { get; }
- /// <inheritdoc />
- public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
- {
- DisposeTimer();
+ /// <inheritdoc />
+ public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
+ {
+ DisposeTimer();
- var now = DateTime.Now;
+ var now = DateTime.Now;
- var triggerDate = now.TimeOfDay > _timeOfDay ? now.Date.AddDays(1) : now.Date;
- triggerDate = triggerDate.Add(_timeOfDay);
+ var triggerDate = now.TimeOfDay > _timeOfDay ? now.Date.AddDays(1) : now.Date;
+ triggerDate = triggerDate.Add(_timeOfDay);
- var dueTime = triggerDate - now;
+ var dueTime = triggerDate - now;
- logger.LogInformation("Daily trigger for {Task} set to fire at {TriggerDate:yyyy-MM-dd HH:mm:ss.fff zzz}, which is {DueTime:c} from now.", taskName, triggerDate, dueTime);
+ logger.LogInformation("Daily trigger for {Task} set to fire at {TriggerDate:yyyy-MM-dd HH:mm:ss.fff zzz}, which is {DueTime:c} from now.", taskName, triggerDate, dueTime);
- _timer = new Timer(_ => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1));
- }
+ _timer = new Timer(_ => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1));
+ }
- /// <inheritdoc />
- public void Stop()
- {
- DisposeTimer();
- }
+ /// <inheritdoc />
+ public void Stop()
+ {
+ DisposeTimer();
+ }
- /// <summary>
- /// Disposes the timer.
- /// </summary>
- private void DisposeTimer()
- {
- _timer?.Dispose();
- _timer = null;
- }
+ /// <summary>
+ /// Disposes the timer.
+ /// </summary>
+ private void DisposeTimer()
+ {
+ _timer?.Dispose();
+ _timer = null;
+ }
- /// <summary>
- /// Called when [triggered].
- /// </summary>
- private void OnTriggered()
- {
- Triggered?.Invoke(this, EventArgs.Empty);
- }
+ /// <summary>
+ /// Called when [triggered].
+ /// </summary>
+ private void OnTriggered()
+ {
+ Triggered?.Invoke(this, EventArgs.Empty);
+ }
- /// <inheritdoc />
- public void Dispose()
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ if (_disposed)
{
- if (_disposed)
- {
- return;
- }
+ return;
+ }
- DisposeTimer();
+ DisposeTimer();
- _disposed = true;
- }
+ _disposed = true;
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs
index 9425b47d0..d6773b65e 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs
@@ -4,104 +4,103 @@ using System.Threading;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks.Triggers
+namespace Emby.Server.Implementations.ScheduledTasks.Triggers;
+
+/// <summary>
+/// Represents a task trigger that runs repeatedly on an interval.
+/// </summary>
+public sealed class IntervalTrigger : ITaskTrigger, IDisposable
{
+ private readonly TimeSpan _interval;
+ private DateTime _lastStartDate;
+ private Timer? _timer;
+ private bool _disposed;
+
/// <summary>
- /// Represents a task trigger that runs repeatedly on an interval.
+ /// Initializes a new instance of the <see cref="IntervalTrigger"/> class.
/// </summary>
- public sealed class IntervalTrigger : ITaskTrigger, IDisposable
+ /// <param name="interval">The interval.</param>
+ /// <param name="taskOptions">The options of this task.</param>
+ public IntervalTrigger(TimeSpan interval, TaskOptions taskOptions)
{
- private readonly TimeSpan _interval;
- private DateTime _lastStartDate;
- private Timer? _timer;
- private bool _disposed = false;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="IntervalTrigger"/> class.
- /// </summary>
- /// <param name="interval">The interval.</param>
- /// <param name="taskOptions">The options of this task.</param>
- public IntervalTrigger(TimeSpan interval, TaskOptions taskOptions)
- {
- _interval = interval;
- TaskOptions = taskOptions;
- }
+ _interval = interval;
+ TaskOptions = taskOptions;
+ }
+
+ /// <inheritdoc />
+ public event EventHandler<EventArgs>? Triggered;
- /// <inheritdoc />
- public event EventHandler<EventArgs>? Triggered;
+ /// <inheritdoc />
+ public TaskOptions TaskOptions { get; }
+
+ /// <inheritdoc />
+ public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
+ {
+ DisposeTimer();
- /// <inheritdoc />
- public TaskOptions TaskOptions { get; }
+ DateTime now = DateTime.UtcNow;
+ DateTime triggerDate;
- /// <inheritdoc />
- public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
+ if (lastResult is null)
{
- DisposeTimer();
-
- DateTime now = DateTime.UtcNow;
- DateTime triggerDate;
-
- if (lastResult is null)
- {
- // Task has never been completed before
- triggerDate = now.AddHours(1);
- }
- else
- {
- triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate, now.AddMinutes(1) }.Max().Add(_interval);
- }
-
- var dueTime = triggerDate - now;
- var maxDueTime = TimeSpan.FromDays(7);
-
- if (dueTime > maxDueTime)
- {
- dueTime = maxDueTime;
- }
-
- _timer = new Timer(_ => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1));
+ // Task has never been completed before
+ triggerDate = now.AddHours(1);
}
-
- /// <inheritdoc />
- public void Stop()
+ else
{
- DisposeTimer();
+ triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate, now.AddMinutes(1) }.Max().Add(_interval);
}
- /// <summary>
- /// Disposes the timer.
- /// </summary>
- private void DisposeTimer()
+ var dueTime = triggerDate - now;
+ var maxDueTime = TimeSpan.FromDays(7);
+
+ if (dueTime > maxDueTime)
{
- _timer?.Dispose();
- _timer = null;
+ dueTime = maxDueTime;
}
- /// <summary>
- /// Called when [triggered].
- /// </summary>
- private void OnTriggered()
- {
- DisposeTimer();
+ _timer = new Timer(_ => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1));
+ }
+
+ /// <inheritdoc />
+ public void Stop()
+ {
+ DisposeTimer();
+ }
- if (Triggered is not null)
- {
- _lastStartDate = DateTime.UtcNow;
- Triggered(this, EventArgs.Empty);
- }
+ /// <summary>
+ /// Disposes the timer.
+ /// </summary>
+ private void DisposeTimer()
+ {
+ _timer?.Dispose();
+ _timer = null;
+ }
+
+ /// <summary>
+ /// Called when [triggered].
+ /// </summary>
+ private void OnTriggered()
+ {
+ DisposeTimer();
+
+ if (Triggered is not null)
+ {
+ _lastStartDate = DateTime.UtcNow;
+ Triggered(this, EventArgs.Empty);
}
+ }
- /// <inheritdoc />
- public void Dispose()
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ if (_disposed)
{
- if (_disposed)
- {
- return;
- }
+ return;
+ }
- DisposeTimer();
+ DisposeTimer();
- _disposed = true;
- }
+ _disposed = true;
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs
index 535aa20f9..86ceff6ce 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/StartupTrigger.cs
@@ -3,52 +3,51 @@ using System.Threading.Tasks;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks.Triggers
+namespace Emby.Server.Implementations.ScheduledTasks.Triggers;
+
+/// <summary>
+/// Class StartupTaskTrigger.
+/// </summary>
+public sealed class StartupTrigger : ITaskTrigger
{
+ private const int DelayMs = 3000;
+
/// <summary>
- /// Class StartupTaskTrigger.
+ /// Initializes a new instance of the <see cref="StartupTrigger"/> class.
/// </summary>
- public sealed class StartupTrigger : ITaskTrigger
+ /// <param name="taskOptions">The options of this task.</param>
+ public StartupTrigger(TaskOptions taskOptions)
{
- private const int DelayMs = 3000;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="StartupTrigger"/> class.
- /// </summary>
- /// <param name="taskOptions">The options of this task.</param>
- public StartupTrigger(TaskOptions taskOptions)
- {
- TaskOptions = taskOptions;
- }
+ TaskOptions = taskOptions;
+ }
- /// <inheritdoc />
- public event EventHandler<EventArgs>? Triggered;
+ /// <inheritdoc />
+ public event EventHandler<EventArgs>? Triggered;
- /// <inheritdoc />
- public TaskOptions TaskOptions { get; }
+ /// <inheritdoc />
+ public TaskOptions TaskOptions { get; }
- /// <inheritdoc />
- public async void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
+ /// <inheritdoc />
+ public async void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
+ {
+ if (isApplicationStartup)
{
- if (isApplicationStartup)
- {
- await Task.Delay(DelayMs).ConfigureAwait(false);
+ await Task.Delay(DelayMs).ConfigureAwait(false);
- OnTriggered();
- }
+ OnTriggered();
}
+ }
- /// <inheritdoc />
- public void Stop()
- {
- }
+ /// <inheritdoc />
+ public void Stop()
+ {
+ }
- /// <summary>
- /// Called when [triggered].
- /// </summary>
- private void OnTriggered()
- {
- Triggered?.Invoke(this, EventArgs.Empty);
- }
+ /// <summary>
+ /// Called when [triggered].
+ /// </summary>
+ private void OnTriggered()
+ {
+ Triggered?.Invoke(this, EventArgs.Empty);
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs
index ad94fdda5..79568f8a1 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/WeeklyTrigger.cs
@@ -3,108 +3,107 @@ using System.Threading;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-namespace Emby.Server.Implementations.ScheduledTasks.Triggers
+namespace Emby.Server.Implementations.ScheduledTasks.Triggers;
+
+/// <summary>
+/// Represents a task trigger that fires on a weekly basis.
+/// </summary>
+public sealed class WeeklyTrigger : ITaskTrigger, IDisposable
{
+ private readonly TimeSpan _timeOfDay;
+ private readonly DayOfWeek _dayOfWeek;
+ private Timer? _timer;
+ private bool _disposed;
+
/// <summary>
- /// Represents a task trigger that fires on a weekly basis.
+ /// Initializes a new instance of the <see cref="WeeklyTrigger"/> class.
/// </summary>
- public sealed class WeeklyTrigger : ITaskTrigger, IDisposable
+ /// <param name="timeOfDay">The time of day to trigger the task to run.</param>
+ /// <param name="dayOfWeek">The day of week.</param>
+ /// <param name="taskOptions">The options of this task.</param>
+ public WeeklyTrigger(TimeSpan timeOfDay, DayOfWeek dayOfWeek, TaskOptions taskOptions)
{
- private readonly TimeSpan _timeOfDay;
- private readonly DayOfWeek _dayOfWeek;
- private Timer? _timer;
- private bool _disposed;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="WeeklyTrigger"/> class.
- /// </summary>
- /// <param name="timeofDay">The time of day to trigger the task to run.</param>
- /// <param name="dayOfWeek">The day of week.</param>
- /// <param name="taskOptions">The options of this task.</param>
- public WeeklyTrigger(TimeSpan timeofDay, DayOfWeek dayOfWeek, TaskOptions taskOptions)
- {
- _timeOfDay = timeofDay;
- _dayOfWeek = dayOfWeek;
- TaskOptions = taskOptions;
- }
+ _timeOfDay = timeOfDay;
+ _dayOfWeek = dayOfWeek;
+ TaskOptions = taskOptions;
+ }
- /// <inheritdoc />
- public event EventHandler<EventArgs>? Triggered;
+ /// <inheritdoc />
+ public event EventHandler<EventArgs>? Triggered;
- /// <inheritdoc />
- public TaskOptions TaskOptions { get; }
+ /// <inheritdoc />
+ public TaskOptions TaskOptions { get; }
- /// <inheritdoc />
- public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
- {
- DisposeTimer();
+ /// <inheritdoc />
+ public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
+ {
+ DisposeTimer();
- var triggerDate = GetNextTriggerDateTime();
+ var triggerDate = GetNextTriggerDateTime();
- _timer = new Timer(_ => OnTriggered(), null, triggerDate - DateTime.Now, TimeSpan.FromMilliseconds(-1));
- }
+ _timer = new Timer(_ => OnTriggered(), null, triggerDate - DateTime.Now, TimeSpan.FromMilliseconds(-1));
+ }
- /// <summary>
- /// Gets the next trigger date time.
- /// </summary>
- /// <returns>DateTime.</returns>
- private DateTime GetNextTriggerDateTime()
+ /// <summary>
+ /// Gets the next trigger date time.
+ /// </summary>
+ /// <returns>DateTime.</returns>
+ private DateTime GetNextTriggerDateTime()
+ {
+ var now = DateTime.Now;
+
+ // If it's on the same day
+ if (now.DayOfWeek == _dayOfWeek)
{
- var now = DateTime.Now;
+ // It's either later today, or a week from now
+ return now.TimeOfDay < _timeOfDay ? now.Date.Add(_timeOfDay) : now.Date.AddDays(7).Add(_timeOfDay);
+ }
- // If it's on the same day
- if (now.DayOfWeek == _dayOfWeek)
- {
- // It's either later today, or a week from now
- return now.TimeOfDay < _timeOfDay ? now.Date.Add(_timeOfDay) : now.Date.AddDays(7).Add(_timeOfDay);
- }
+ var triggerDate = now.Date;
- var triggerDate = now.Date;
+ // Walk the date forward until we get to the trigger day
+ while (triggerDate.DayOfWeek != _dayOfWeek)
+ {
+ triggerDate = triggerDate.AddDays(1);
+ }
- // Walk the date forward until we get to the trigger day
- while (triggerDate.DayOfWeek != _dayOfWeek)
- {
- triggerDate = triggerDate.AddDays(1);
- }
+ // Return the trigger date plus the time offset
+ return triggerDate.Add(_timeOfDay);
+ }
- // Return the trigger date plus the time offset
- return triggerDate.Add(_timeOfDay);
- }
+ /// <inheritdoc />
+ public void Stop()
+ {
+ DisposeTimer();
+ }
- /// <inheritdoc />
- public void Stop()
- {
- DisposeTimer();
- }
+ /// <summary>
+ /// Disposes the timer.
+ /// </summary>
+ private void DisposeTimer()
+ {
+ _timer?.Dispose();
+ _timer = null;
+ }
- /// <summary>
- /// Disposes the timer.
- /// </summary>
- private void DisposeTimer()
- {
- _timer?.Dispose();
- _timer = null;
- }
+ /// <summary>
+ /// Called when [triggered].
+ /// </summary>
+ private void OnTriggered()
+ {
+ Triggered?.Invoke(this, EventArgs.Empty);
+ }
- /// <summary>
- /// Called when [triggered].
- /// </summary>
- private void OnTriggered()
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ if (_disposed)
{
- Triggered?.Invoke(this, EventArgs.Empty);
+ return;
}
- /// <inheritdoc />
- public void Dispose()
- {
- if (_disposed)
- {
- return;
- }
-
- DisposeTimer();
+ DisposeTimer();
- _disposed = true;
- }
+ _disposed = true;
}
}
diff --git a/Emby.Server.Implementations/ServerApplicationPaths.cs b/Emby.Server.Implementations/ServerApplicationPaths.cs
index 725df98da..f049e6647 100644
--- a/Emby.Server.Implementations/ServerApplicationPaths.cs
+++ b/Emby.Server.Implementations/ServerApplicationPaths.cs
@@ -96,5 +96,12 @@ namespace Emby.Server.Implementations
/// <inheritdoc />
public string VirtualInternalMetadataPath => "%MetadataPath%";
+
+ /// <inheritdoc/>
+ public override void MakeSanityCheckOrThrow()
+ {
+ base.MakeSanityCheckOrThrow();
+ CreateAndCheckMarker(RootFolderPath, "root");
+ }
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 924f50286..cf2ca047c 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -456,7 +456,7 @@ namespace Emby.Server.Implementations.Session
var nowPlayingQueue = info.NowPlayingQueue;
- if (nowPlayingQueue?.Length > 0)
+ if (nowPlayingQueue?.Length > 0 && !nowPlayingQueue.SequenceEqual(session.NowPlayingQueue))
{
session.NowPlayingQueue = nowPlayingQueue;
@@ -474,6 +474,7 @@ namespace Emby.Server.Implementations.Session
private void RemoveNowPlayingItem(SessionInfo session)
{
session.NowPlayingItem = null;
+ session.FullNowPlayingItem = null;
session.PlayState = new PlayerStateInfo();
if (!string.IsNullOrEmpty(session.DeviceId))
@@ -508,13 +509,11 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var key = GetSessionKey(appName, deviceId);
-
- CheckDisposed();
-
- if (!_activeConnections.TryGetValue(key, out var sessionInfo))
+ SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
+ SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
+ if (ReferenceEquals(newSession, sessionInfo))
{
- sessionInfo = CreateSession(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
- _activeConnections[key] = sessionInfo;
+ OnSessionStarted(newSession);
}
sessionInfo.UserId = user?.Id ?? Guid.Empty;
@@ -538,7 +537,7 @@ namespace Emby.Server.Implementations.Session
return sessionInfo;
}
- private SessionInfo CreateSession(
+ private SessionInfo CreateSessionInfo(
string key,
string appName,
string appVersion,
@@ -582,7 +581,6 @@ namespace Emby.Server.Implementations.Session
sessionInfo.HasCustomDeviceName = true;
}
- OnSessionStarted(sessionInfo);
return sessionInfo;
}
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index d4606abd2..6a26e92e1 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -5,6 +5,8 @@ using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Net.WebSocketMessages.Outbound;
using MediaBrowser.Controller.Session;
@@ -44,6 +46,7 @@ namespace Emby.Server.Implementations.Session
private readonly Lock _webSocketsLock = new();
private readonly ISessionManager _sessionManager;
+ private readonly IUserManager _userManager;
private readonly ILogger<SessionWebSocketListener> _logger;
private readonly ILoggerFactory _loggerFactory;
@@ -57,14 +60,17 @@ namespace Emby.Server.Implementations.Session
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="sessionManager">The session manager.</param>
+ /// <param name="userManager">The user manager.</param>
/// <param name="loggerFactory">The logger factory.</param>
public SessionWebSocketListener(
ILogger<SessionWebSocketListener> logger,
ISessionManager sessionManager,
+ IUserManager userManager,
ILoggerFactory loggerFactory)
{
_logger = logger;
_sessionManager = sessionManager;
+ _userManager = userManager;
_loggerFactory = loggerFactory;
_keepAlive = new System.Timers.Timer(TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor))
{
@@ -107,33 +113,9 @@ namespace Emby.Server.Implementations.Session
/// <inheritdoc />
public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection, HttpContext httpContext)
{
- var session = await GetSession(httpContext, connection.RemoteEndPoint?.ToString()).ConfigureAwait(false);
- if (session is not null)
- {
- EnsureController(session, connection);
- await KeepAliveWebSocket(connection).ConfigureAwait(false);
- }
- else
- {
- _logger.LogWarning("Unable to determine session based on query string: {0}", httpContext.Request.QueryString);
- }
- }
-
- private async Task<SessionInfo?> GetSession(HttpContext httpContext, string? remoteEndpoint)
- {
- if (!httpContext.User.Identity?.IsAuthenticated ?? false)
- {
- return null;
- }
-
- var deviceId = httpContext.User.GetDeviceId();
- if (httpContext.Request.Query.TryGetValue("deviceId", out var queryDeviceId))
- {
- deviceId = queryDeviceId;
- }
-
- return await _sessionManager.GetSessionByAuthenticationToken(httpContext.User.GetToken(), deviceId, remoteEndpoint)
- .ConfigureAwait(false);
+ var session = await RequestHelpers.GetSession(_sessionManager, _userManager, httpContext).ConfigureAwait(false);
+ EnsureController(session, connection);
+ await KeepAliveWebSocket(connection).ConfigureAwait(false);
}
private void EnsureController(SessionInfo session, IWebSocketConnection connection)
diff --git a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
index 9afc51108..f10e7fcbb 100644
--- a/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
+++ b/Emby.Server.Implementations/Sorting/DateLastMediaAddedComparer.cs
@@ -26,10 +26,10 @@ namespace Emby.Server.Implementations.Sorting
public IUserManager UserManager { get; set; }
/// <summary>
- /// Gets or sets the user data repository.
+ /// Gets or sets the user data manager.
/// </summary>
- /// <value>The user data repository.</value>
- public IUserDataManager UserDataRepository { get; set; }
+ /// <value>The user data manager.</value>
+ public IUserDataManager UserDataManager { get; set; }
/// <summary>
/// Gets the name.
diff --git a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs
index 4c013a8bd..2c8e2b37d 100644
--- a/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs
+++ b/Emby.Server.Implementations/Sorting/DatePlayedComparer.cs
@@ -28,10 +28,10 @@ namespace Emby.Server.Implementations.Sorting
public IUserManager UserManager { get; set; }
/// <summary>
- /// Gets or sets the user data repository.
+ /// Gets or sets the user data manager.
/// </summary>
- /// <value>The user data repository.</value>
- public IUserDataManager UserDataRepository { get; set; }
+ /// <value>The user data manager.</value>
+ public IUserDataManager UserDataManager { get; set; }
/// <summary>
/// Gets the name.
@@ -57,7 +57,7 @@ namespace Emby.Server.Implementations.Sorting
/// <returns>DateTime.</returns>
private DateTime GetDate(BaseItem x)
{
- var userdata = UserDataRepository.GetUserData(User, x);
+ var userdata = UserDataManager.GetUserData(User, x);
if (userdata is not null && userdata.LastPlayedDate.HasValue)
{
diff --git a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs
index cf7786167..86d08ed27 100644
--- a/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs
+++ b/Emby.Server.Implementations/Sorting/IsFavoriteOrLikeComparer.cs
@@ -6,7 +6,6 @@ using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Sorting;
-using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Sorting
{
@@ -25,10 +24,10 @@ namespace Emby.Server.Implementations.Sorting
public ItemSortBy Type => ItemSortBy.IsFavoriteOrLiked;
/// <summary>
- /// Gets or sets the user data repository.
+ /// Gets or sets the user data manager.
/// </summary>
- /// <value>The user data repository.</value>
- public IUserDataManager UserDataRepository { get; set; }
+ /// <value>The user data manager.</value>
+ public IUserDataManager UserDataManager { get; set; }
/// <summary>
/// Gets or sets the user manager.
@@ -54,7 +53,7 @@ namespace Emby.Server.Implementations.Sorting
/// <returns>DateTime.</returns>
private int GetValue(BaseItem x)
{
- return x.IsFavoriteOrLiked(User) ? 0 : 1;
+ return x.IsFavoriteOrLiked(User, userItemData: null) ? 0 : 1;
}
}
}
diff --git a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs
index e42c8a33a..9faa02f1f 100644
--- a/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs
+++ b/Emby.Server.Implementations/Sorting/IsPlayedComparer.cs
@@ -7,7 +7,6 @@ using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Sorting;
-using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Sorting
{
@@ -26,10 +25,10 @@ namespace Emby.Server.Implementations.Sorting
public ItemSortBy Type => ItemSortBy.IsUnplayed;
/// <summary>
- /// Gets or sets the user data repository.
+ /// Gets or sets the user data manager.
/// </summary>
- /// <value>The user data repository.</value>
- public IUserDataManager UserDataRepository { get; set; }
+ /// <value>The user data manager.</value>
+ public IUserDataManager UserDataManager { get; set; }
/// <summary>
/// Gets or sets the user manager.
@@ -55,7 +54,7 @@ namespace Emby.Server.Implementations.Sorting
/// <returns>DateTime.</returns>
private int GetValue(BaseItem x)
{
- return x.IsPlayed(User) ? 0 : 1;
+ return x.IsPlayed(User, userItemData: null) ? 0 : 1;
}
}
}
diff --git a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs
index f54188030..6f177c463 100644
--- a/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs
+++ b/Emby.Server.Implementations/Sorting/IsUnplayedComparer.cs
@@ -7,7 +7,6 @@ using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Sorting;
-using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Sorting
{
@@ -26,10 +25,10 @@ namespace Emby.Server.Implementations.Sorting
public ItemSortBy Type => ItemSortBy.IsUnplayed;
/// <summary>
- /// Gets or sets the user data repository.
+ /// Gets or sets the user data manager.
/// </summary>
- /// <value>The user data repository.</value>
- public IUserDataManager UserDataRepository { get; set; }
+ /// <value>The user data manager.</value>
+ public IUserDataManager UserDataManager { get; set; }
/// <summary>
/// Gets or sets the user manager.
@@ -55,7 +54,7 @@ namespace Emby.Server.Implementations.Sorting
/// <returns>DateTime.</returns>
private int GetValue(BaseItem x)
{
- return x.IsUnplayed(User) ? 0 : 1;
+ return x.IsUnplayed(User, userItemData: null) ? 0 : 1;
}
}
}
diff --git a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs
index dd2149b57..26e28b03b 100644
--- a/Emby.Server.Implementations/Sorting/PlayCountComparer.cs
+++ b/Emby.Server.Implementations/Sorting/PlayCountComparer.cs
@@ -27,10 +27,10 @@ namespace Emby.Server.Implementations.Sorting
public ItemSortBy Type => ItemSortBy.PlayCount;
/// <summary>
- /// Gets or sets the user data repository.
+ /// Gets or sets the user data manager.
/// </summary>
- /// <value>The user data repository.</value>
- public IUserDataManager UserDataRepository { get; set; }
+ /// <value>The user data manager.</value>
+ public IUserDataManager UserDataManager { get; set; }
/// <summary>
/// Gets or sets the user manager.
@@ -56,7 +56,7 @@ namespace Emby.Server.Implementations.Sorting
/// <returns>DateTime.</returns>
private int GetValue(BaseItem x)
{
- var userdata = UserDataRepository.GetUserData(User, x);
+ var userdata = UserDataManager.GetUserData(User, x);
return userdata is null ? 0 : userdata.PlayCount;
}
diff --git a/Emby.Server.Implementations/Sorting/StartDateComparer.cs b/Emby.Server.Implementations/Sorting/StartDateComparer.cs
index e0b438ef1..861ca2d3a 100644
--- a/Emby.Server.Implementations/Sorting/StartDateComparer.cs
+++ b/Emby.Server.Implementations/Sorting/StartDateComparer.cs
@@ -5,7 +5,6 @@ using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Sorting;
-using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Sorting
{
diff --git a/Emby.Server.Implementations/SystemManager.cs b/Emby.Server.Implementations/SystemManager.cs
index 92b59b23c..d140426dd 100644
--- a/Emby.Server.Implementations/SystemManager.cs
+++ b/Emby.Server.Implementations/SystemManager.cs
@@ -85,7 +85,10 @@ public class SystemManager : ISystemManager
/// <inheritdoc/>
public SystemStorageInfo GetSystemStorageInfo()
{
- var virtualFolderInfos = _libraryManager.GetVirtualFolders().Select(e => new LibraryStorageInfo()
+ var virtualFolderInfos = _libraryManager
+ .GetVirtualFolders()
+ .Where(e => !string.IsNullOrWhiteSpace(e.ItemId)) // this should not be null but for some users it is.
+ .Select(e => new LibraryStorageInfo()
{
Id = Guid.Parse(e.ItemId),
Name = e.Name,
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index 678475b31..5ff400160 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -223,15 +223,14 @@ namespace Emby.Server.Implementations.Updates
Guid id = default,
Version? specificVersion = null)
{
- if (name is not null)
- {
- availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
- }
-
if (!id.IsEmpty())
{
availablePackages = availablePackages.Where(x => x.Id.Equals(id));
}
+ else if (name is not null)
+ {
+ availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ }
if (specificVersion is not null)
{
diff --git a/Jellyfin.Api/Controllers/BackupController.cs b/Jellyfin.Api/Controllers/BackupController.cs
new file mode 100644
index 000000000..aa908ee30
--- /dev/null
+++ b/Jellyfin.Api/Controllers/BackupController.cs
@@ -0,0 +1,127 @@
+using System.IO;
+using System.Threading.Tasks;
+using Jellyfin.Server.Implementations.SystemBackupService;
+using MediaBrowser.Common.Api;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.SystemBackupService;
+using Microsoft.AspNetCore.Authentication.OAuth.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace Jellyfin.Api.Controllers;
+
+/// <summary>
+/// The backup controller.
+/// </summary>
+[Authorize(Policy = Policies.RequiresElevation)]
+public class BackupController : BaseJellyfinApiController
+{
+ private readonly IBackupService _backupService;
+ private readonly IApplicationPaths _applicationPaths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BackupController"/> class.
+ /// </summary>
+ /// <param name="backupService">Instance of the <see cref="IBackupService"/> interface.</param>
+ /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ public BackupController(IBackupService backupService, IApplicationPaths applicationPaths)
+ {
+ _backupService = backupService;
+ _applicationPaths = applicationPaths;
+ }
+
+ /// <summary>
+ /// Creates a new Backup.
+ /// </summary>
+ /// <param name="backupOptions">The backup options.</param>
+ /// <response code="200">Backup created.</response>
+ /// <response code="403">User does not have permission to retrieve information.</response>
+ /// <returns>The created backup manifest.</returns>
+ [HttpPost("Create")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult<BackupManifestDto>> CreateBackup([FromBody] BackupOptionsDto backupOptions)
+ {
+ return Ok(await _backupService.CreateBackupAsync(backupOptions ?? new()).ConfigureAwait(false));
+ }
+
+ /// <summary>
+ /// Restores to a backup by restarting the server and applying the backup.
+ /// </summary>
+ /// <param name="archiveRestoreDto">The data to start a restore process.</param>
+ /// <response code="204">Backup restore started.</response>
+ /// <response code="403">User does not have permission to retrieve information.</response>
+ /// <returns>No-Content.</returns>
+ [HttpPost("Restore")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public IActionResult StartRestoreBackup([FromBody, BindRequired] BackupRestoreRequestDto archiveRestoreDto)
+ {
+ var archivePath = SanitizePath(archiveRestoreDto.ArchiveFileName);
+ if (!System.IO.File.Exists(archivePath))
+ {
+ return NotFound();
+ }
+
+ _backupService.ScheduleRestoreAndRestartServer(archivePath);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Gets a list of all currently present backups in the backup directory.
+ /// </summary>
+ /// <response code="200">Backups available.</response>
+ /// <response code="403">User does not have permission to retrieve information.</response>
+ /// <returns>The list of backups.</returns>
+ [HttpGet]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult<BackupManifestDto[]>> ListBackups()
+ {
+ return Ok(await _backupService.EnumerateBackups().ConfigureAwait(false));
+ }
+
+ /// <summary>
+ /// Gets the descriptor from an existing archive is present.
+ /// </summary>
+ /// <param name="path">The data to start a restore process.</param>
+ /// <response code="200">Backup archive manifest.</response>
+ /// <response code="204">Not a valid jellyfin Archive.</response>
+ /// <response code="404">Not a valid path.</response>
+ /// <response code="403">User does not have permission to retrieve information.</response>
+ /// <returns>The backup manifest.</returns>
+ [HttpGet("Manifest")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult<BackupManifestDto>> GetBackup([BindRequired] string path)
+ {
+ var backupPath = SanitizePath(path);
+
+ if (!System.IO.File.Exists(backupPath))
+ {
+ return NotFound();
+ }
+
+ var manifest = await _backupService.GetBackupManifest(backupPath).ConfigureAwait(false);
+ if (manifest is null)
+ {
+ return NoContent();
+ }
+
+ return Ok(manifest);
+ }
+
+ [NonAction]
+ private string SanitizePath(string path)
+ {
+ // sanitize path
+ var archiveRestorePath = Path.GetFileName(Path.GetFullPath(path));
+ var archivePath = Path.Combine(_applicationPaths.BackupPath, archiveRestorePath);
+ return archivePath;
+ }
+}
diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
index 13064882c..585318d24 100644
--- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
+++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
@@ -96,9 +96,6 @@ public class DisplayPreferencesController : BaseJellyfinApiController
dto.CustomPrefs.TryAdd(key, value);
}
- // This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
- _displayPreferencesManager.SaveChanges();
-
return dto;
}
@@ -210,8 +207,8 @@ public class DisplayPreferencesController : BaseJellyfinApiController
// Set all remaining custom preferences.
_displayPreferencesManager.SetCustomItemDisplayPreferences(userId.Value, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
- _displayPreferencesManager.SaveChanges();
-
+ _displayPreferencesManager.UpdateItemDisplayPreferences(itemPrefs);
+ _displayPreferencesManager.UpdateDisplayPreferences(existingDisplayPreferences);
return NoContent();
}
}
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index 4cac8ed67..fe6f855b5 100644
--- a/Jellyfin.Api/Controllers/DynamicHlsController.cs
+++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs
@@ -46,6 +46,7 @@ public class DynamicHlsController : BaseJellyfinApiController
private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0);
private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1);
+ private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0);
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
@@ -1606,6 +1607,7 @@ public class DynamicHlsController : BaseJellyfinApiController
var segmentFormat = string.Empty;
var segmentContainer = outputExtension.TrimStart('.');
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
+ var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
{
@@ -1621,6 +1623,14 @@ public class DynamicHlsController : BaseJellyfinApiController
false => " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\""
};
+ var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions;
+
+ if (state.VideoStream is not null && state.IsOutputVideo)
+ {
+ // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
+ hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
+ }
+
segmentFormat = "fmp4" + outputFmp4HeaderArg;
}
else
@@ -1642,8 +1652,6 @@ public class DynamicHlsController : BaseJellyfinApiController
Path.GetFileNameWithoutExtension(outputPath));
}
- var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
-
return string.Format(
CultureInfo.InvariantCulture,
"{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9}{10} -hls_segment_filename \"{11}\" {12} -y \"{13}\"",
diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs
index abda053d3..cd8132d21 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
+using System.Drawing;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -1458,19 +1459,6 @@ public class ImageController : BaseJellyfinApiController
/// <param name="userId">User id.</param>
/// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
- /// <param name="maxWidth">The maximum image width to return.</param>
- /// <param name="maxHeight">The maximum image height to return.</param>
- /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
- /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
- /// <param name="width">The fixed image width to return.</param>
- /// <param name="height">The fixed image height to return.</param>
- /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
- /// <param name="fillWidth">Width of box to fill.</param>
- /// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="blur">Optional. Blur image.</param>
- /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
- /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
- /// <param name="imageIndex">Image index.</param>
/// <response code="200">Image stream returned.</response>
/// <response code="400">User id not provided.</response>
/// <response code="404">Item not found.</response>
@@ -1487,20 +1475,7 @@ public class ImageController : BaseJellyfinApiController
public async Task<ActionResult> GetUserImage(
[FromQuery] Guid? userId,
[FromQuery] string? tag,
- [FromQuery] ImageFormat? format,
- [FromQuery] int? maxWidth,
- [FromQuery] int? maxHeight,
- [FromQuery] double? percentPlayed,
- [FromQuery] int? unplayedCount,
- [FromQuery] int? width,
- [FromQuery] int? height,
- [FromQuery] int? quality,
- [FromQuery] int? fillWidth,
- [FromQuery] int? fillHeight,
- [FromQuery] int? blur,
- [FromQuery] string? backgroundColor,
- [FromQuery] string? foregroundLayer,
- [FromQuery] int? imageIndex)
+ [FromQuery] ImageFormat? format)
{
var requestUserId = userId ?? User.GetUserId();
if (requestUserId.IsEmpty())
@@ -1521,34 +1496,24 @@ public class ImageController : BaseJellyfinApiController
DateModified = user.ProfileImage.LastModified
};
- if (width.HasValue)
- {
- info.Width = width.Value;
- }
-
- if (height.HasValue)
- {
- info.Height = height.Value;
- }
-
return await GetImageInternal(
user.Id,
ImageType.Profile,
- imageIndex,
+ null,
tag,
format,
- maxWidth,
- maxHeight,
- percentPlayed,
- unplayedCount,
- width,
- height,
- quality,
- fillWidth,
- fillHeight,
- blur,
- backgroundColor,
- foregroundLayer,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 90,
+ null,
+ null,
+ null,
+ null,
+ null,
null,
info)
.ConfigureAwait(false);
@@ -1608,20 +1573,7 @@ public class ImageController : BaseJellyfinApiController
=> GetUserImage(
userId,
tag,
- format,
- maxWidth,
- maxHeight,
- percentPlayed,
- unplayedCount,
- width,
- height,
- quality,
- fillWidth,
- fillHeight,
- blur,
- backgroundColor,
- foregroundLayer,
- imageIndex);
+ format);
/// <summary>
/// Get user profile image.
@@ -1677,36 +1629,13 @@ public class ImageController : BaseJellyfinApiController
=> GetUserImage(
userId,
tag,
- format,
- maxWidth,
- maxHeight,
- percentPlayed,
- unplayedCount,
- width,
- height,
- quality,
- fillWidth,
- fillHeight,
- blur,
- backgroundColor,
- foregroundLayer,
- imageIndex);
+ format);
/// <summary>
/// Generates or gets the splashscreen.
/// </summary>
/// <param name="tag">Supply the cache tag from the item object to receive strong caching headers.</param>
/// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
- /// <param name="maxWidth">The maximum image width to return.</param>
- /// <param name="maxHeight">The maximum image height to return.</param>
- /// <param name="width">The fixed image width to return.</param>
- /// <param name="height">The fixed image height to return.</param>
- /// <param name="fillWidth">Width of box to fill.</param>
- /// <param name="fillHeight">Height of box to fill.</param>
- /// <param name="blur">Blur image.</param>
- /// <param name="backgroundColor">Apply a background color for transparent images.</param>
- /// <param name="foregroundLayer">Apply a foreground layer on top of the image.</param>
- /// <param name="quality">Quality setting, from 0-100.</param>
/// <response code="200">Splashscreen returned successfully.</response>
/// <returns>The splashscreen.</returns>
[HttpGet("Branding/Splashscreen")]
@@ -1714,17 +1643,7 @@ public class ImageController : BaseJellyfinApiController
[ProducesImageFile]
public async Task<ActionResult> GetSplashscreen(
[FromQuery] string? tag,
- [FromQuery] ImageFormat? format,
- [FromQuery] int? maxWidth,
- [FromQuery] int? maxHeight,
- [FromQuery] int? width,
- [FromQuery] int? height,
- [FromQuery] int? fillWidth,
- [FromQuery] int? fillHeight,
- [FromQuery] int? blur,
- [FromQuery] string? backgroundColor,
- [FromQuery] string? foregroundLayer,
- [FromQuery, Range(0, 100)] int quality = 90)
+ [FromQuery] ImageFormat? format)
{
var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding");
var isAdmin = User.IsInRole(Constants.UserRoles.Administrator);
@@ -1763,16 +1682,16 @@ public class ImageController : BaseJellyfinApiController
{
Path = splashscreenPath
},
- Height = height,
- MaxHeight = maxHeight,
- MaxWidth = maxWidth,
- FillHeight = fillHeight,
- FillWidth = fillWidth,
- Quality = quality,
- Width = width,
- Blur = blur,
- BackgroundColor = backgroundColor,
- ForegroundLayer = foregroundLayer,
+ Height = null,
+ MaxHeight = null,
+ MaxWidth = null,
+ FillHeight = null,
+ FillWidth = null,
+ Quality = 90,
+ Width = null,
+ Blur = null,
+ BackgroundColor = null,
+ ForegroundLayer = null,
SupportedOutputFormats = outputFormats
};
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index d49e0753e..e1d9b6bba 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -158,7 +158,10 @@ public class ItemUpdateController : BaseJellyfinApiController
ParentalRatingOptions = _localizationManager.GetParentalRatings().ToList(),
ExternalIdInfos = _providerManager.GetExternalIdInfos(item).ToArray(),
Countries = _localizationManager.GetCountries().ToArray(),
- Cultures = _localizationManager.GetCultures().ToArray()
+ Cultures = _localizationManager.GetCultures()
+ .DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(c => c.DisplayName)
+ .ToArray()
};
if (!item.IsVirtualItem
@@ -299,7 +302,7 @@ public class ItemUpdateController : BaseJellyfinApiController
if (!season.LockedFields.Contains(MetadataField.Tags))
{
- season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
season.OnMetadataChanged();
@@ -316,7 +319,7 @@ public class ItemUpdateController : BaseJellyfinApiController
if (!ep.LockedFields.Contains(MetadataField.Tags))
{
- ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
ep.OnMetadataChanged();
@@ -337,7 +340,7 @@ public class ItemUpdateController : BaseJellyfinApiController
if (!ep.LockedFields.Contains(MetadataField.Tags))
{
- ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
ep.OnMetadataChanged();
@@ -357,7 +360,7 @@ public class ItemUpdateController : BaseJellyfinApiController
if (!track.LockedFields.Contains(MetadataField.Tags))
{
- track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
track.OnMetadataChanged();
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index bde1758e9..4c9cc2b1e 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -779,11 +779,14 @@ public class LibraryController : BaseJellyfinApiController
var query = new InternalItemsQuery(user)
{
Genres = item.Genres,
+ Tags = item.Tags,
Limit = limit,
IncludeItemTypes = includeItemTypes.ToArray(),
DtoOptions = dtoOptions,
EnableTotalRecordCount = !isMovie ?? true,
EnableGroupByMetadataKey = isMovie ?? false,
+ ExcludeItemIds = [itemId],
+ OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
};
// ExcludeArtistIds
diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs
index bbce5a9e1..dd8f935dc 100644
--- a/Jellyfin.Api/Controllers/LocalizationController.cs
+++ b/Jellyfin.Api/Controllers/LocalizationController.cs
@@ -1,4 +1,6 @@
+using System;
using System.Collections.Generic;
+using System.Linq;
using MediaBrowser.Common.Api;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -34,7 +36,14 @@ public class LocalizationController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<CultureDto>> GetCultures()
{
- return Ok(_localization.GetCultures());
+ var allCultures = _localization.GetCultures();
+
+ var distinctCultures = allCultures
+ .DistinctBy(c => c.DisplayName, StringComparer.OrdinalIgnoreCase)
+ .OrderBy(c => c.DisplayName)
+ .AsEnumerable();
+
+ return Ok(distinctCultures);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/MediaSegmentsController.cs b/Jellyfin.Api/Controllers/MediaSegmentsController.cs
index e30e2b54e..b8836d7cf 100644
--- a/Jellyfin.Api/Controllers/MediaSegmentsController.cs
+++ b/Jellyfin.Api/Controllers/MediaSegmentsController.cs
@@ -5,9 +5,9 @@ using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Extensions;
using Jellyfin.Database.Implementations.Enums;
-using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Model.MediaSegments;
using MediaBrowser.Model.Querying;
using Microsoft.AspNetCore.Authorization;
@@ -55,7 +55,8 @@ public class MediaSegmentsController : BaseJellyfinApiController
return NotFound();
}
- var items = await _mediaSegmentManager.GetSegmentsAsync(item, includeSegmentTypes).ConfigureAwait(false);
+ var libraryOptions = _libraryManager.GetLibraryOptions(item);
+ var items = await _mediaSegmentManager.GetSegmentsAsync(item, includeSegmentTypes, libraryOptions).ConfigureAwait(false);
return Ok(new QueryResult<MediaSegmentDto>(items.ToArray()));
}
}
diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs
index ec5fdab38..79c71d23a 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -450,22 +450,41 @@ public class PlaylistsController : BaseJellyfinApiController
{
var callingUserId = User.GetUserId();
- var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId);
- if (playlist is null)
+ if (!callingUserId.IsEmpty())
{
- return NotFound("Playlist not found");
+ var playlist = _playlistManager.GetPlaylistForUser(Guid.Parse(playlistId), callingUserId);
+ if (playlist is null)
+ {
+ return NotFound("Playlist not found");
+ }
+
+ var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
+ || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
+
+ if (!isPermitted)
+ {
+ return Forbid();
+ }
}
+ else
+ {
+ var isApiKey = User.GetIsApiKey();
- var isPermitted = playlist.OwnerUserId.Equals(callingUserId)
- || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId));
+ if (!isApiKey)
+ {
+ return Forbid();
+ }
+ }
- if (!isPermitted)
+ try
{
- return Forbid();
+ await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
+ return NoContent();
+ }
+ catch (ArgumentException)
+ {
+ return NotFound();
}
-
- await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
- return NoContent();
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs
index 1577b4594..ade0906b3 100644
--- a/Jellyfin.Api/Controllers/PlaystateController.cs
+++ b/Jellyfin.Api/Controllers/PlaystateController.cs
@@ -272,6 +272,7 @@ public class PlaystateController : BaseJellyfinApiController
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("PlayingItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [Obsolete("This endpoint is obsolete. Use ReportPlaybackStart instead")]
public async Task<ActionResult> OnPlaybackStart(
[FromRoute, Required] Guid itemId,
[FromQuery] string? mediaSourceId,
@@ -350,6 +351,7 @@ public class PlaystateController : BaseJellyfinApiController
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("PlayingItems/{itemId}/Progress")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [Obsolete("This endpoint is obsolete. Use ReportPlaybackProgress instead")]
public async Task<ActionResult> OnPlaybackProgress(
[FromRoute, Required] Guid itemId,
[FromQuery] string? mediaSourceId,
@@ -438,6 +440,7 @@ public class PlaystateController : BaseJellyfinApiController
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("PlayingItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [Obsolete("This endpoint is obsolete. Use ReportPlaybackStop instead")]
public async Task<ActionResult> OnPlaybackStopped(
[FromRoute, Required] Guid itemId,
[FromQuery] string? mediaSourceId,
diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs
index fbab2a784..3d6874079 100644
--- a/Jellyfin.Api/Controllers/SyncPlayController.cs
+++ b/Jellyfin.Api/Controllers/SyncPlayController.cs
@@ -125,7 +125,7 @@ public class SyncPlayController : BaseJellyfinApiController
{
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
var group = _syncPlayManager.GetGroup(currentSession, id);
- return group == null ? NotFound() : Ok(group);
+ return group is null ? NotFound() : Ok(group);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs
index 07a1f7650..450225c37 100644
--- a/Jellyfin.Api/Controllers/SystemController.cs
+++ b/Jellyfin.Api/Controllers/SystemController.cs
@@ -5,7 +5,6 @@ using System.IO;
using System.Linq;
using System.Net.Mime;
using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.SystemInfoDtos;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Configuration;
diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs
index ebf98da45..5495f60d8 100644
--- a/Jellyfin.Api/Controllers/YearsController.cs
+++ b/Jellyfin.Api/Controllers/YearsController.cs
@@ -108,6 +108,7 @@ public class YearsController : BaseJellyfinApiController
bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
IReadOnlyList<BaseItem> items;
+ int totalCount = -1;
if (parentItem.IsFolder)
{
var folder = (Folder)parentItem;
@@ -118,7 +119,7 @@ public class YearsController : BaseJellyfinApiController
}
else
{
- items = recursive ? folder.GetRecursiveChildren(user, query) : folder.GetChildren(user, true).Where(Filter).ToArray();
+ items = recursive ? folder.GetRecursiveChildren(user, query, out totalCount) : folder.GetChildren(user, true).Where(Filter).ToArray();
}
}
else
@@ -153,7 +154,7 @@ public class YearsController : BaseJellyfinApiController
var result = new QueryResult<BaseItemDto>(
startIndex,
- ibnItemsArray.Count,
+ totalCount == -1 ? ibnItemsArray.Count : totalCount,
dtos.Where(i => i is not null).ToArray());
return result;
}
@@ -223,6 +224,6 @@ public class YearsController : BaseJellyfinApiController
.Select(i => i.ProductionYear ?? 0)
.Where(i => i > 0)
.Distinct()
- .Select(year => _libraryManager.GetYear(year));
+ .Select(_libraryManager.GetYear);
}
}
diff --git a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs
index 8dbb91d0a..46256c09d 100644
--- a/Jellyfin.Api/Formatters/XmlOutputFormatter.cs
+++ b/Jellyfin.Api/Formatters/XmlOutputFormatter.cs
@@ -1,4 +1,8 @@
+using System;
using System.Net.Mime;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace Jellyfin.Api.Formatters;
@@ -6,7 +10,7 @@ namespace Jellyfin.Api.Formatters;
/// <summary>
/// Xml output formatter.
/// </summary>
-public sealed class XmlOutputFormatter : StringOutputFormatter
+public sealed class XmlOutputFormatter : TextOutputFormatter
{
/// <summary>
/// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
@@ -15,5 +19,24 @@ public sealed class XmlOutputFormatter : StringOutputFormatter
{
SupportedMediaTypes.Clear();
SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
+
+ SupportedEncodings.Add(Encoding.UTF8);
+ SupportedEncodings.Add(Encoding.Unicode);
+ }
+
+ /// <inheritdoc />
+ public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(selectedEncoding);
+
+ var valueAsString = context.Object?.ToString();
+ if (string.IsNullOrEmpty(valueAsString))
+ {
+ return;
+ }
+
+ var response = context.HttpContext.Response;
+ await response.WriteAsync(valueAsString, selectedEncoding).ConfigureAwait(false);
}
}
diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
index 0690f0c8d..4034a8088 100644
--- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
+++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
@@ -32,17 +32,67 @@ public static class FileStreamResponseHelpers
HttpContext httpContext,
CancellationToken cancellationToken = default)
{
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri(state.MediaPath));
+
+ // Forward User-Agent if provided
if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
{
- httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, useragent);
+ // Clear default and add specific one if exists, otherwise HttpClient default might be used
+ requestMessage.Headers.UserAgent.Clear();
+ requestMessage.Headers.TryAddWithoutValidation(HeaderNames.UserAgent, useragent);
+ }
+
+ // Forward Range header if present in the client request
+ if (httpContext.Request.Headers.TryGetValue(HeaderNames.Range, out var rangeValue))
+ {
+ var rangeString = rangeValue.ToString();
+ if (!string.IsNullOrEmpty(rangeString))
+ {
+ requestMessage.Headers.Range = System.Net.Http.Headers.RangeHeaderValue.Parse(rangeString);
+ }
}
- // Can't dispose the response as it's required up the call chain.
- var response = await httpClient.GetAsync(new Uri(state.MediaPath), HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
- var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Text.Plain;
+ // Send the request to the upstream server
+ // Use ResponseHeadersRead to avoid downloading the whole content immediately
+ var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
- httpContext.Response.Headers[HeaderNames.AcceptRanges] = "none";
+ // Check if the upstream server supports range requests and acted upon our Range header
+ bool upstreamSupportsRange = response.StatusCode == System.Net.HttpStatusCode.PartialContent;
+ string acceptRangesValue = "none";
+ if (response.Headers.TryGetValues(HeaderNames.AcceptRanges, out var acceptRangesHeaders))
+ {
+ // Prefer upstream server's Accept-Ranges header if available
+ acceptRangesValue = string.Join(", ", acceptRangesHeaders);
+ upstreamSupportsRange |= acceptRangesValue.Contains("bytes", StringComparison.OrdinalIgnoreCase);
+ }
+ else if (upstreamSupportsRange) // If we got 206 but no Accept-Ranges header, assume bytes
+ {
+ acceptRangesValue = "bytes";
+ }
+
+ // Set Accept-Ranges header for the client based on upstream support
+ httpContext.Response.Headers[HeaderNames.AcceptRanges] = acceptRangesValue;
+
+ // Set Content-Range header if upstream provided it (implies partial content)
+ if (response.Content.Headers.ContentRange is not null)
+ {
+ httpContext.Response.Headers[HeaderNames.ContentRange] = response.Content.Headers.ContentRange.ToString();
+ }
+
+ // Set Content-Length header. For partial content, this is the length of the partial segment.
+ if (response.Content.Headers.ContentLength.HasValue)
+ {
+ httpContext.Response.ContentLength = response.Content.Headers.ContentLength.Value;
+ }
+
+ // Set Content-Type header
+ var contentType = response.Content.Headers.ContentType?.ToString() ?? MediaTypeNames.Application.Octet; // Use a more generic default
+
+ // Set the status code for the client response (e.g., 200 OK or 206 Partial Content)
+ httpContext.Response.StatusCode = (int)response.StatusCode;
+ // Return the stream from the upstream server
+ // IMPORTANT: Do not dispose the response stream here, FileStreamResult will handle it.
return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType);
}
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index e10e940f2..5072f902d 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -111,7 +111,16 @@ public static class RequestHelpers
return user.EnableUserPreferenceAccess;
}
- internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null)
+ /// <summary>
+ /// Get the session based on http request.
+ /// </summary>
+ /// <param name="sessionManager">The session manager.</param>
+ /// <param name="userManager">The user manager.</param>
+ /// <param name="httpContext">The http context.</param>
+ /// <param name="userId">The optional userid.</param>
+ /// <returns>The session.</returns>
+ /// <exception cref="ResourceNotFoundException">Session not found.</exception>
+ public static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null)
{
userId ??= httpContext.User.GetUserId();
User? user = null;
diff --git a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs
index 842a69dd9..a0ed6c812 100644
--- a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs
+++ b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs
@@ -1,8 +1,10 @@
using System.Net;
using System.Threading.Tasks;
+using System.Web;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
namespace Jellyfin.Api.Middleware;
@@ -12,14 +14,17 @@ namespace Jellyfin.Api.Middleware;
public class IPBasedAccessValidationMiddleware
{
private readonly RequestDelegate _next;
+ private readonly ILogger<IPBasedAccessValidationMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="IPBasedAccessValidationMiddleware"/> class.
/// </summary>
/// <param name="next">The next delegate in the pipeline.</param>
- public IPBasedAccessValidationMiddleware(RequestDelegate next)
+ /// <param name="logger">The logger to log to.</param>
+ public IPBasedAccessValidationMiddleware(RequestDelegate next, ILogger<IPBasedAccessValidationMiddleware> logger)
{
_next = next;
+ _logger = logger;
}
/// <summary>
@@ -32,16 +37,23 @@ public class IPBasedAccessValidationMiddleware
{
if (httpContext.IsLocal())
{
- // Running locally.
+ // Accessing from the same machine as the server.
await _next(httpContext).ConfigureAwait(false);
return;
}
- var remoteIP = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback;
+ var remoteIP = httpContext.GetNormalizedRemoteIP();
- if (!networkManager.HasRemoteAccess(remoteIP))
+ var result = networkManager.ShouldAllowServerAccess(remoteIP);
+ if (result != RemoteAccessPolicyResult.Allow)
{
// No access from network, respond with 503 instead of 200.
+ _logger.LogWarning(
+ "Blocking request to {Path} by {RemoteIP} due to IP filtering rule, reason: {Reason}",
+ // url-encode to block log injection
+ HttpUtility.UrlEncode(httpContext.Request.Path),
+ remoteIP,
+ result);
httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
return;
}
diff --git a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs
deleted file mode 100644
index 35b0a1dd0..000000000
--- a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-using System.Net;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Configuration;
-using Microsoft.AspNetCore.Http;
-
-namespace Jellyfin.Api.Middleware;
-
-/// <summary>
-/// Validates the LAN host IP based on application configuration.
-/// </summary>
-public class LanFilteringMiddleware
-{
- private readonly RequestDelegate _next;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="LanFilteringMiddleware"/> class.
- /// </summary>
- /// <param name="next">The next delegate in the pipeline.</param>
- public LanFilteringMiddleware(RequestDelegate next)
- {
- _next = next;
- }
-
- /// <summary>
- /// Executes the middleware action.
- /// </summary>
- /// <param name="httpContext">The current HTTP context.</param>
- /// <param name="networkManager">The network manager.</param>
- /// <param name="serverConfigurationManager">The server configuration manager.</param>
- /// <returns>The async task.</returns>
- public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
- {
- if (serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess)
- {
- await _next(httpContext).ConfigureAwait(false);
- return;
- }
-
- var host = httpContext.GetNormalizedRemoteIP();
- if (!networkManager.IsInLocalNetwork(host))
- {
- // No access from network, respond with 503 instead of 200.
- httpContext.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
- return;
- }
-
- await _next(httpContext).ConfigureAwait(false);
- }
-}
diff --git a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs
index 9d149cc85..143d82bac 100644
--- a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs
+++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
@@ -57,6 +58,21 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
}
/// <inheritdoc />
+ protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection)
+ {
+ // For non-admin users, filter the sessions to only include their own sessions
+ if (connection.AuthorizationInfo?.User is not null &&
+ !connection.AuthorizationInfo.IsApiKey &&
+ !connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
+ {
+ var userId = connection.AuthorizationInfo.User.Id;
+ return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId)));
+ }
+
+ return Task.FromResult(_sessionManager.Sessions);
+ }
+
+ /// <inheritdoc />
protected override async ValueTask DisposeAsyncCore()
{
if (!_disposed)
@@ -80,11 +96,10 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
/// <param name="message">The message.</param>
protected override void Start(WebSocketMessageInfo message)
{
- if (!message.Connection.AuthorizationInfo.IsApiKey
- && (message.Connection.AuthorizationInfo.User is null
- || !message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)))
+ // Allow all authenticated users to subscribe to session information
+ if (message.Connection.AuthorizationInfo.User is null && !message.Connection.AuthorizationInfo.IsApiKey)
{
- throw new AuthenticationException("Only admin users can subscribe to session information.");
+ throw new AuthenticationException("User must be authenticated to subscribe to session Information.");
}
base.Start(message);
diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj
index 45374c22f..fd852ece9 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>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
index fbbb5bca7..932f9d625 100644
--- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
+++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
@@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
+using System.IO;
+using System.Linq;
using System.Reflection;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
+using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
@@ -41,6 +44,28 @@ public static class ServiceCollectionExtensions
return items;
}
+ private static JellyfinDbProviderFactory? LoadDatabasePlugin(CustomDatabaseOptions customProviderOptions, IApplicationPaths applicationPaths)
+ {
+ var plugin = Directory.EnumerateDirectories(applicationPaths.PluginsPath)
+ .Where(e => Path.GetFileName(e)!.StartsWith(customProviderOptions.PluginName, StringComparison.OrdinalIgnoreCase))
+ .Order()
+ .FirstOrDefault()
+ ?? throw new InvalidOperationException($"The requested custom database plugin with the name '{customProviderOptions.PluginName}' could not been found in '{applicationPaths.PluginsPath}'");
+
+ var dbProviderAssembly = Path.Combine(plugin, Path.ChangeExtension(customProviderOptions.PluginAssembly, "dll"));
+ if (!File.Exists(dbProviderAssembly))
+ {
+ throw new InvalidOperationException($"Could not find the requested assembly at '{dbProviderAssembly}'");
+ }
+
+ // we have to load the assembly without proxy to ensure maximum performance for this.
+ var assembly = Assembly.LoadFrom(dbProviderAssembly);
+ var dbProviderType = assembly.GetExportedTypes().FirstOrDefault(f => f.IsAssignableTo(typeof(IJellyfinDatabaseProvider)))
+ ?? throw new InvalidOperationException($"Could not find any type implementing the '{nameof(IJellyfinDatabaseProvider)}' interface.");
+
+ return (services) => (IJellyfinDatabaseProvider)ActivatorUtilities.CreateInstance(services, dbProviderType);
+ }
+
/// <summary>
/// Adds the <see cref="IDbContextFactory{TContext}"/> interface to the service collection with second level caching enabled.
/// </summary>
@@ -54,7 +79,6 @@ public static class ServiceCollectionExtensions
IConfiguration configuration)
{
var efCoreConfiguration = configurationManager.GetConfiguration<DatabaseConfigurationOptions>("database");
- var providers = GetSupportedDbProviders();
JellyfinDbProviderFactory? providerFactory = null;
if (efCoreConfiguration?.DatabaseType is null)
@@ -73,22 +97,51 @@ public static class ServiceCollectionExtensions
efCoreConfiguration = new DatabaseConfigurationOptions()
{
DatabaseType = "Jellyfin-SQLite",
+ LockingBehavior = DatabaseLockingBehaviorTypes.NoLock
};
configurationManager.SaveConfiguration("database", efCoreConfiguration);
}
}
- if (!providers.TryGetValue(efCoreConfiguration.DatabaseType.ToUpperInvariant(), out providerFactory!))
+ if (efCoreConfiguration.DatabaseType.Equals("PLUGIN_PROVIDER", StringComparison.OrdinalIgnoreCase))
+ {
+ if (efCoreConfiguration.CustomProviderOptions is null)
+ {
+ throw new InvalidOperationException("The custom database provider must declare the custom provider options to work");
+ }
+
+ providerFactory = LoadDatabasePlugin(efCoreConfiguration.CustomProviderOptions, configurationManager.ApplicationPaths);
+ }
+ else
{
- throw new InvalidOperationException($"Jellyfin cannot find the database provider of type '{efCoreConfiguration.DatabaseType}'. Supported types are {string.Join(", ", providers.Keys)}");
+ var providers = GetSupportedDbProviders();
+ if (!providers.TryGetValue(efCoreConfiguration.DatabaseType.ToUpperInvariant(), out providerFactory!))
+ {
+ throw new InvalidOperationException($"Jellyfin cannot find the database provider of type '{efCoreConfiguration.DatabaseType}'. Supported types are {string.Join(", ", providers.Keys)}");
+ }
}
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
+ switch (efCoreConfiguration.LockingBehavior)
+ {
+ case DatabaseLockingBehaviorTypes.NoLock:
+ serviceCollection.AddSingleton<IEntityFrameworkCoreLockingBehavior, NoLockBehavior>();
+ break;
+ case DatabaseLockingBehaviorTypes.Pessimistic:
+ serviceCollection.AddSingleton<IEntityFrameworkCoreLockingBehavior, PessimisticLockBehavior>();
+ break;
+ case DatabaseLockingBehaviorTypes.Optimistic:
+ serviceCollection.AddSingleton<IEntityFrameworkCoreLockingBehavior, OptimisticLockBehavior>();
+ break;
+ }
+
serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) =>
{
var provider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
- provider.Initialise(opt);
+ provider.Initialise(opt, efCoreConfiguration);
+ var lockingBehavior = serviceProvider.GetRequiredService<IEntityFrameworkCoreLockingBehavior>();
+ lockingBehavior.Initialise(opt);
});
return serviceCollection;
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs
new file mode 100644
index 000000000..77a49b2b5
--- /dev/null
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs
@@ -0,0 +1,19 @@
+using System;
+
+namespace Jellyfin.Server.Implementations.FullSystemBackup;
+
+/// <summary>
+/// Manifest type for backups internal structure.
+/// </summary>
+internal class BackupManifest
+{
+ public required Version ServerVersion { get; set; }
+
+ public required Version BackupEngineVersion { get; set; }
+
+ public required DateTimeOffset DateCreated { get; set; }
+
+ public required string[] DatabaseTables { get; set; }
+
+ public required BackupOptions Options { get; set; }
+}
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs
new file mode 100644
index 000000000..8bd108c44
--- /dev/null
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs
@@ -0,0 +1,15 @@
+namespace Jellyfin.Server.Implementations.FullSystemBackup;
+
+/// <summary>
+/// Defines the optional contents of the backup archive.
+/// </summary>
+internal class BackupOptions
+{
+ public bool Metadata { get; set; }
+
+ public bool Trickplay { get; set; }
+
+ public bool Subtitles { get; set; }
+
+ public bool Database { get; set; }
+}
diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
new file mode 100644
index 000000000..70483c36c
--- /dev/null
+++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs
@@ -0,0 +1,559 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.StorageHelpers;
+using Jellyfin.Server.Implementations.SystemBackupService;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.SystemBackupService;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Implementations.FullSystemBackup;
+
+/// <summary>
+/// Contains methods for creating and restoring backups.
+/// </summary>
+public class BackupService : IBackupService
+{
+ private const string ManifestEntryName = "manifest.json";
+ private readonly ILogger<BackupService> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IServerApplicationHost _applicationHost;
+ private readonly IServerApplicationPaths _applicationPaths;
+ private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
+ private readonly IHostApplicationLifetime _hostApplicationLifetime;
+ private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
+ {
+ AllowTrailingCommas = true,
+ ReferenceHandler = ReferenceHandler.IgnoreCycles,
+ };
+
+ private readonly Version _backupEngineVersion = new Version(0, 2, 0);
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BackupService"/> class.
+ /// </summary>
+ /// <param name="logger">A logger.</param>
+ /// <param name="dbProvider">A Database Factory.</param>
+ /// <param name="applicationHost">The Application host.</param>
+ /// <param name="applicationPaths">The application paths.</param>
+ /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
+ /// <param name="applicationLifetime">The SystemManager.</param>
+ public BackupService(
+ ILogger<BackupService> logger,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ IServerApplicationHost applicationHost,
+ IServerApplicationPaths applicationPaths,
+ IJellyfinDatabaseProvider jellyfinDatabaseProvider,
+ IHostApplicationLifetime applicationLifetime)
+ {
+ _logger = logger;
+ _dbProvider = dbProvider;
+ _applicationHost = applicationHost;
+ _applicationPaths = applicationPaths;
+ _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+ _hostApplicationLifetime = applicationLifetime;
+ }
+
+ /// <inheritdoc/>
+ public void ScheduleRestoreAndRestartServer(string archivePath)
+ {
+ _applicationHost.RestoreBackupPath = archivePath;
+ _applicationHost.ShouldRestart = true;
+ _applicationHost.NotifyPendingRestart();
+ _ = Task.Run(async () =>
+ {
+ await Task.Delay(500).ConfigureAwait(false);
+ _hostApplicationLifetime.StopApplication();
+ });
+ }
+
+ /// <inheritdoc/>
+ public async Task RestoreBackupAsync(string archivePath)
+ {
+ _logger.LogWarning("Begin restoring system to {BackupArchive}", archivePath); // Info isn't cutting it
+ if (!File.Exists(archivePath))
+ {
+ throw new FileNotFoundException($"Requested backup file '{archivePath}' does not exist.");
+ }
+
+ StorageHelper.TestCommonPathsForStorageCapacity(_applicationPaths, _logger);
+
+ var fileStream = File.OpenRead(archivePath);
+ await using (fileStream.ConfigureAwait(false))
+ {
+ using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, false);
+ var zipArchiveEntry = zipArchive.GetEntry(ManifestEntryName);
+
+ if (zipArchiveEntry is null)
+ {
+ throw new NotSupportedException($"The loaded archive '{archivePath}' does not appear to be a Jellyfin backup as its missing the '{ManifestEntryName}'.");
+ }
+
+ BackupManifest? manifest;
+ var manifestStream = zipArchiveEntry.Open();
+ await using (manifestStream.ConfigureAwait(false))
+ {
+ manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
+ }
+
+ if (manifest!.ServerVersion > _applicationHost.ApplicationVersion) // newer versions of Jellyfin should be able to load older versions as we have migrations.
+ {
+ throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
+ }
+
+ if (!TestBackupVersionCompatibility(manifest.BackupEngineVersion))
+ {
+ throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
+ }
+
+ void CopyDirectory(string source, string target)
+ {
+ var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
+ var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
+ foreach (var item in zipArchive.Entries)
+ {
+ var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
+ var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
+
+ if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
+ || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
+ || Path.EndsInDirectorySeparator(item.FullName))
+ {
+ continue;
+ }
+
+ _logger.LogInformation("Restore and override {File}", targetPath);
+
+ Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
+ item.ExtractToFile(targetPath, overwrite: true);
+ }
+ }
+
+ CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
+ CopyDirectory("Data", _applicationPaths.DataPath);
+ CopyDirectory("Root", _applicationPaths.RootFolderPath);
+
+ if (manifest.Options.Database)
+ {
+ _logger.LogInformation("Begin restoring Database");
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ // restore migration history manually
+ var historyEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{nameof(HistoryRow)}.json")));
+ if (historyEntry is null)
+ {
+ _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
+ throw new InvalidOperationException("Cannot restore backup that has no History data.");
+ }
+
+ HistoryRow[] historyEntries;
+ var historyArchive = historyEntry.Open();
+ await using (historyArchive.ConfigureAwait(false))
+ {
+ historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
+ throw new InvalidOperationException("Cannot restore backup that has no History data.");
+ }
+
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
+
+ foreach (var item in await historyRepository.GetAppliedMigrationsAsync(CancellationToken.None).ConfigureAwait(false))
+ {
+ var insertScript = historyRepository.GetDeleteScript(item.MigrationId);
+ await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
+ }
+
+ foreach (var item in historyEntries)
+ {
+ var insertScript = historyRepository.GetInsertScript(item);
+ await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
+ }
+
+ dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
+ var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
+ .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
+ .Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
+ .ToArray();
+
+ var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!);
+ _logger.LogInformation("Begin purging database");
+ await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
+ _logger.LogInformation("Database Purged");
+
+ foreach (var entityType in entityTypes)
+ {
+ _logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
+
+ var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
+ if (zipEntry is null)
+ {
+ _logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name);
+ continue;
+ }
+
+ var zipEntryStream = zipEntry.Open();
+ await using (zipEntryStream.ConfigureAwait(false))
+ {
+ _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
+ var records = 0;
+ await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false))
+ {
+ var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
+ if (entity is null)
+ {
+ throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
+ }
+
+ try
+ {
+ records++;
+ dbContext.Add(entity);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
+ }
+ }
+
+ _logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityType.Type.Name);
+ }
+ }
+
+ _logger.LogInformation("Try restore Database");
+ await dbContext.SaveChangesAsync().ConfigureAwait(false);
+ _logger.LogInformation("Restored database");
+ }
+ }
+
+ _logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
+ }
+ }
+
+ private bool TestBackupVersionCompatibility(Version backupEngineVersion)
+ {
+ if (backupEngineVersion == _backupEngineVersion)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
+ {
+ var manifest = new BackupManifest()
+ {
+ DateCreated = DateTime.UtcNow,
+ ServerVersion = _applicationHost.ApplicationVersion,
+ DatabaseTables = null!,
+ BackupEngineVersion = _backupEngineVersion,
+ Options = Map(backupOptions)
+ };
+
+ _logger.LogInformation("Running database optimization before backup");
+
+ await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
+
+ var backupFolder = Path.Combine(_applicationPaths.BackupPath);
+
+ if (!Directory.Exists(backupFolder))
+ {
+ Directory.CreateDirectory(backupFolder);
+ }
+
+ var backupStorageSpace = StorageHelper.GetFreeSpaceOf(_applicationPaths.BackupPath);
+
+ const long FiveGigabyte = 5_368_709_115;
+ if (backupStorageSpace.FreeSpace < FiveGigabyte)
+ {
+ throw new InvalidOperationException($"The backup directory '{backupStorageSpace.Path}' does not have at least '{StorageHelper.HumanizeStorageSize(FiveGigabyte)}' free space. Cannot create backup.");
+ }
+
+ var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
+
+ try
+ {
+ _logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
+ var fileStream = File.OpenWrite(backupPath);
+ await using (fileStream.ConfigureAwait(false))
+ using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
+ {
+ _logger.LogInformation("Starting backup process");
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
+
+ static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
+ {
+ var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
+ var enumerable = method.Invoke(dbSet, null)!;
+ return (IAsyncEnumerable<object>)enumerable;
+ }
+
+ // include the migration history as well
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
+
+ ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
+ [
+ .. typeof(JellyfinDbContext)
+ .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
+ .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
+ .Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
+ (Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
+ ];
+ manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
+ var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
+
+ await using (transaction.ConfigureAwait(false))
+ {
+ _logger.LogInformation("Begin Database backup");
+
+ foreach (var entityType in entityTypes)
+ {
+ _logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
+ var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
+ var entities = 0;
+ var zipEntryStream = zipEntry.Open();
+ await using (zipEntryStream.ConfigureAwait(false))
+ {
+ var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
+ await using (jsonSerializer.ConfigureAwait(false))
+ {
+ jsonSerializer.WriteStartArray();
+
+ var set = entityType.ValueFactory().ConfigureAwait(false);
+ await foreach (var item in set.ConfigureAwait(false))
+ {
+ entities++;
+ try
+ {
+ using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
+ document.WriteTo(jsonSerializer);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Could not load entity {Entity}", item);
+ throw;
+ }
+ }
+
+ jsonSerializer.WriteEndArray();
+ }
+ }
+
+ _logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
+ }
+ }
+ }
+
+ _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
+ foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
+ .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
+ {
+ zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
+ }
+
+ void CopyDirectory(string source, string target, string filter = "*")
+ {
+ if (!Directory.Exists(source))
+ {
+ return;
+ }
+
+ _logger.LogInformation("Backup of folder {Table}", source);
+
+ foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
+ {
+ zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
+ }
+ }
+
+ CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
+ CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
+ CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
+ if (backupOptions.Subtitles)
+ {
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
+ }
+
+ if (backupOptions.Trickplay)
+ {
+ CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
+ }
+
+ if (backupOptions.Metadata)
+ {
+ CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
+ }
+
+ var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
+ await using (manifestStream.ConfigureAwait(false))
+ {
+ await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
+ }
+ }
+
+ _logger.LogInformation("Backup created");
+ return Map(manifest, backupPath);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
+ try
+ {
+ if (File.Exists(backupPath))
+ {
+ File.Delete(backupPath);
+ }
+ }
+ catch (Exception innerEx)
+ {
+ _logger.LogWarning(innerEx, "Unable to remove failed backup");
+ }
+
+ throw;
+ }
+ }
+
+ /// <inheritdoc/>
+ public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
+ {
+ if (!File.Exists(archivePath))
+ {
+ return null;
+ }
+
+ BackupManifest? manifest;
+ try
+ {
+ manifest = await GetManifest(archivePath).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
+ return null;
+ }
+
+ if (manifest is null)
+ {
+ return null;
+ }
+
+ return Map(manifest, archivePath);
+ }
+
+ /// <inheritdoc/>
+ public async Task<BackupManifestDto[]> EnumerateBackups()
+ {
+ if (!Directory.Exists(_applicationPaths.BackupPath))
+ {
+ return [];
+ }
+
+ var archives = Directory.EnumerateFiles(_applicationPaths.BackupPath, "*.zip");
+ var manifests = new List<BackupManifestDto>();
+ foreach (var item in archives)
+ {
+ try
+ {
+ var manifest = await GetManifest(item).ConfigureAwait(false);
+
+ if (manifest is null)
+ {
+ continue;
+ }
+
+ manifests.Add(Map(manifest, item));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
+ }
+ }
+
+ return manifests.ToArray();
+ }
+
+ private static async ValueTask<BackupManifest?> GetManifest(string archivePath)
+ {
+ var archiveStream = File.OpenRead(archivePath);
+ await using (archiveStream.ConfigureAwait(false))
+ {
+ using var zipStream = new ZipArchive(archiveStream, ZipArchiveMode.Read);
+ var manifestEntry = zipStream.GetEntry(ManifestEntryName);
+ if (manifestEntry is null)
+ {
+ return null;
+ }
+
+ var manifestStream = manifestEntry.Open();
+ await using (manifestStream.ConfigureAwait(false))
+ {
+ return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private static BackupManifestDto Map(BackupManifest manifest, string path)
+ {
+ return new BackupManifestDto()
+ {
+ BackupEngineVersion = manifest.BackupEngineVersion,
+ DateCreated = manifest.DateCreated,
+ ServerVersion = manifest.ServerVersion,
+ Path = path,
+ Options = Map(manifest.Options)
+ };
+ }
+
+ private static BackupOptionsDto Map(BackupOptions options)
+ {
+ return new BackupOptionsDto()
+ {
+ Metadata = options.Metadata,
+ Subtitles = options.Subtitles,
+ Trickplay = options.Trickplay,
+ Database = options.Database
+ };
+ }
+
+ private static BackupOptions Map(BackupOptionsDto options)
+ {
+ return new BackupOptions()
+ {
+ Metadata = options.Metadata,
+ Subtitles = options.Subtitles,
+ Trickplay = options.Trickplay,
+ Database = options.Database
+ };
+ }
+
+ /// <summary>
+ /// Windows is able to handle '/' as a path seperator in zip files
+ /// but linux isn't able to handle '\' as a path seperator in zip files,
+ /// So normalize to '/'.
+ /// </summary>
+ /// <param name="path">The path to normalize.</param>
+ /// <returns>The normalized path. </returns>
+ private static string NormalizePathSeparator(string path)
+ => path.Replace('\\', '/');
+}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 7f4364cf6..2c18ce69a 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -14,6 +14,7 @@ using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
@@ -54,6 +55,11 @@ public sealed class BaseItemRepository
: IItemRepository
{
/// <summary>
+ /// Gets the placeholder id for UserData detached items.
+ /// </summary>
+ public static readonly Guid PlaceholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
+
+ /// <summary>
/// This holds all the types in the running assemblies
/// so that we can de-serialize properly when we don't have strong types.
/// </summary>
@@ -69,6 +75,7 @@ public sealed class BaseItemRepository
private static readonly IReadOnlyList<ItemValueType> _getAlbumArtistValueTypes = [ItemValueType.AlbumArtist];
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
+ private static readonly IReadOnlyList<char> SearchWildcardTerms = ['%', '_', '[', ']', '^'];
/// <summary>
/// Initializes a new instance of the <see cref="BaseItemRepository"/> class.
@@ -93,33 +100,59 @@ public sealed class BaseItemRepository
}
/// <inheritdoc />
- public void DeleteItem(Guid id)
+ public void DeleteItem(params IReadOnlyList<Guid> ids)
{
- if (id.IsEmpty())
+ if (ids is null || ids.Count == 0 || ids.Any(f => f.Equals(PlaceholderId)))
{
- throw new ArgumentException("Guid can't be empty", nameof(id));
+ throw new ArgumentException("Guid can't be empty or the placeholder id.", nameof(ids));
}
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
- context.AncestorIds.Where(e => e.ItemId == id || e.ParentItemId == id).ExecuteDelete();
- context.AttachmentStreamInfos.Where(e => e.ItemId == id).ExecuteDelete();
- context.BaseItemImageInfos.Where(e => e.ItemId == id).ExecuteDelete();
- context.BaseItemMetadataFields.Where(e => e.ItemId == id).ExecuteDelete();
- context.BaseItemProviders.Where(e => e.ItemId == id).ExecuteDelete();
- context.BaseItemTrailerTypes.Where(e => e.ItemId == id).ExecuteDelete();
- context.BaseItems.Where(e => e.Id == id).ExecuteDelete();
- context.Chapters.Where(e => e.ItemId == id).ExecuteDelete();
- context.CustomItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete();
- context.ItemDisplayPreferences.Where(e => e.ItemId == id).ExecuteDelete();
+
+ var date = (DateTime?)DateTime.UtcNow;
+
+ var relatedItems = ids.SelectMany(f => TraverseHirachyDown(f, context)).ToArray();
+
+ // Remove any UserData entries for the placeholder item that would conflict with the UserData
+ // being detached from the item being deleted. This is necessary because, during an update,
+ // UserData may be reattached to a new entry, but some entries can be left behind.
+ // Ensures there are no duplicate UserId/CustomDataKey combinations for the placeholder.
+ context.UserData
+ .Join(
+ context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId),
+ placeholder => new { placeholder.UserId, placeholder.CustomDataKey },
+ userData => new { userData.UserId, userData.CustomDataKey },
+ (placeholder, userData) => placeholder)
+ .Where(e => e.ItemId == PlaceholderId)
+ .ExecuteDelete();
+
+ // Detach all user watch data
+ context.UserData.WhereOneOrMany(relatedItems, e => e.ItemId)
+ .ExecuteUpdate(e => e
+ .SetProperty(f => f.RetentionDate, date)
+ .SetProperty(f => f.ItemId, PlaceholderId));
+
+ context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
+ context.AncestorIds.WhereOneOrMany(relatedItems, e => e.ParentItemId).ExecuteDelete();
+ context.AttachmentStreamInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
+ context.BaseItemImageInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
+ context.BaseItemMetadataFields.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
+ context.BaseItemProviders.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
+ context.BaseItemTrailerTypes.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
+ context.BaseItems.WhereOneOrMany(relatedItems, e => e.Id).ExecuteDelete();
+ context.Chapters.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
+ context.CustomItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
+ context.ItemDisplayPreferences.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.ItemValues.Where(e => e.BaseItemsMap!.Count == 0).ExecuteDelete();
- context.ItemValuesMap.Where(e => e.ItemId == id).ExecuteDelete();
- context.KeyframeData.Where(e => e.ItemId == id).ExecuteDelete();
- context.MediaSegments.Where(e => e.ItemId == id).ExecuteDelete();
- context.MediaStreamInfos.Where(e => e.ItemId == id).ExecuteDelete();
- context.PeopleBaseItemMap.Where(e => e.ItemId == id).ExecuteDelete();
- context.Peoples.Where(e => e.BaseItems!.Count == 0).ExecuteDelete();
- context.TrickplayInfos.Where(e => e.ItemId == id).ExecuteDelete();
+ context.ItemValuesMap.WhereOneOrMany(relatedItems, e => e.ItemId).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.TrickplayInfos.WhereOneOrMany(relatedItems, e => e.ItemId).ExecuteDelete();
context.SaveChanges();
transaction.Commit();
}
@@ -144,41 +177,41 @@ public sealed class BaseItemRepository
PrepareFilterQuery(filter);
using var context = _dbProvider.CreateDbContext();
- return ApplyQueryFilter(context.BaseItems.AsNoTracking(), context, filter).Select(e => e.Id).ToArray();
+ return ApplyQueryFilter(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, filter).Select(e => e.Id).ToArray();
}
/// <inheritdoc />
- public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery filter)
+ public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAllArtists(InternalItemsQuery filter)
{
return GetItemValues(filter, _getAllArtistsValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]);
}
/// <inheritdoc />
- public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery filter)
+ public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetArtists(InternalItemsQuery filter)
{
return GetItemValues(filter, _getArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]);
}
/// <inheritdoc />
- public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery filter)
+ public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetAlbumArtists(InternalItemsQuery filter)
{
return GetItemValues(filter, _getAlbumArtistValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]);
}
/// <inheritdoc />
- public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetStudios(InternalItemsQuery filter)
+ public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetStudios(InternalItemsQuery filter)
{
return GetItemValues(filter, _getStudiosValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Studio]);
}
/// <inheritdoc />
- public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter)
+ public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetGenres(InternalItemsQuery filter)
{
return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.Genre]);
}
/// <inheritdoc />
- public QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter)
+ public QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetMusicGenres(InternalItemsQuery filter)
{
return GetItemValues(filter, _getGenreValueTypes, _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicGenre]);
}
@@ -234,15 +267,17 @@ public sealed class BaseItemRepository
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
dbQuery = TranslateQuery(dbQuery, context, filter);
+ dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
+
if (filter.EnableTotalRecordCount)
{
result.TotalRecordCount = dbQuery.Count();
}
- dbQuery = ApplyGroupingFilter(dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
+ dbQuery = ApplyNavigations(dbQuery, filter);
- result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray();
+ result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
result.StartIndex = filter.StartIndex ?? 0;
return result;
}
@@ -258,10 +293,11 @@ public sealed class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
- dbQuery = ApplyGroupingFilter(dbQuery, filter);
+ dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
+ dbQuery = ApplyNavigations(dbQuery, filter);
- return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray();
+ return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
}
/// <inheritdoc/>
@@ -300,10 +336,12 @@ public sealed class BaseItemRepository
var mainquery = PrepareItemQuery(context, filter);
mainquery = TranslateQuery(mainquery, context, filter);
mainquery = mainquery.Where(g => g.DateCreated >= subqueryGrouped.Min(s => s.MaxDateCreated));
- mainquery = ApplyGroupingFilter(mainquery, filter);
+ mainquery = ApplyGroupingFilter(context, mainquery, filter);
mainquery = ApplyQueryPaging(mainquery, filter);
- return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray();
+ mainquery = ApplyNavigations(mainquery, filter);
+
+ return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
}
/// <inheritdoc />
@@ -319,7 +357,7 @@ public sealed class BaseItemRepository
.Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value))
.Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode])
.Join(
- context.UserData.AsNoTracking(),
+ context.UserData.AsNoTracking().Where(e => e.ItemId != EF.Constant(PlaceholderId)),
i => new { UserId = filter.User.Id, ItemId = i.Id },
u => new { UserId = u.UserId, ItemId = u.ItemId },
(entity, data) => new { Item = entity, UserData = data })
@@ -337,36 +375,50 @@ public sealed class BaseItemRepository
return query.ToArray();
}
- private IQueryable<BaseItemEntity> ApplyGroupingFilter(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
+ private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
// This whole block is needed to filter duplicate entries on request
// for the time being it cannot be used because it would destroy the ordering
// this results in "duplicate" responses for queries that try to lookup individual series or multiple versions but
// for that case the invoker has to run a DistinctBy(e => e.PresentationUniqueKey) on their own
- // var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
- // if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
- // {
- // dbQuery = ApplyOrder(dbQuery, filter);
- // dbQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.First());
- // }
- // else if (enableGroupByPresentationUniqueKey)
- // {
- // dbQuery = ApplyOrder(dbQuery, filter);
- // dbQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.First());
- // }
- // else if (filter.GroupBySeriesPresentationUniqueKey)
- // {
- // dbQuery = ApplyOrder(dbQuery, filter);
- // dbQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.First());
- // }
- // else
- // {
- // dbQuery = dbQuery.Distinct();
- // dbQuery = ApplyOrder(dbQuery, filter);
- // }
- dbQuery = dbQuery.Distinct();
- dbQuery = ApplyOrder(dbQuery, filter);
+ var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
+ if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
+ {
+ var tempQuery = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.FirstOrDefault()).Select(e => e!.Id);
+ dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id));
+ }
+ else if (enableGroupByPresentationUniqueKey)
+ {
+ var tempQuery = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id);
+ dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id));
+ }
+ else if (filter.GroupBySeriesPresentationUniqueKey)
+ {
+ var tempQuery = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.FirstOrDefault()).Select(e => e!.Id);
+ dbQuery = context.BaseItems.Where(e => tempQuery.Contains(e.Id));
+ }
+ else
+ {
+ dbQuery = dbQuery.Distinct();
+ }
+
+ dbQuery = ApplyOrder(dbQuery, filter, context);
+
+ return dbQuery;
+ }
+
+ private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
+ {
+ dbQuery = dbQuery.Include(e => e.TrailerTypes)
+ .Include(e => e.Provider)
+ .Include(e => e.LockedFields)
+ .Include(e => e.UserData);
+
+ if (filter.DtoOptions.EnableImages)
+ {
+ dbQuery = dbQuery.Include(e => e.Images);
+ }
return dbQuery;
}
@@ -394,23 +446,16 @@ public sealed class BaseItemRepository
private IQueryable<BaseItemEntity> ApplyQueryFilter(IQueryable<BaseItemEntity> dbQuery, JellyfinDbContext context, InternalItemsQuery filter)
{
dbQuery = TranslateQuery(dbQuery, context, filter);
- dbQuery = ApplyOrder(dbQuery, filter);
- dbQuery = ApplyGroupingFilter(dbQuery, filter);
+ dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
+ dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery;
}
private IQueryable<BaseItemEntity> PrepareItemQuery(JellyfinDbContext context, InternalItemsQuery filter)
{
- IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking().AsSplitQuery()
- .Include(e => e.TrailerTypes)
- .Include(e => e.Provider)
- .Include(e => e.LockedFields);
-
- if (filter.DtoOptions.EnableImages)
- {
- dbQuery = dbQuery.Include(e => e.Images);
- }
+ IQueryable<BaseItemEntity> dbQuery = context.BaseItems.AsNoTracking();
+ dbQuery = dbQuery.AsSingleQuery();
return dbQuery;
}
@@ -428,6 +473,66 @@ public sealed class BaseItemRepository
return dbQuery.Count();
}
+ /// <inheritdoc />
+ public ItemCounts GetItemCounts(InternalItemsQuery filter)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+ // Hack for right now since we currently don't support filtering out these duplicates within a query
+ PrepareFilterQuery(filter);
+
+ using var context = _dbProvider.CreateDbContext();
+ var dbQuery = TranslateQuery(context.BaseItems.AsNoTracking(), context, filter);
+
+ var counts = dbQuery
+ .GroupBy(x => x.Type)
+ .Select(x => new { x.Key, Count = x.Count() })
+ .ToArray();
+
+ var lookup = _itemTypeLookup.BaseItemKindNames;
+ var result = new ItemCounts();
+ foreach (var count in counts)
+ {
+ 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))
+ {
+ result.TrailerCount = count.Count;
+ }
+ }
+
+ return result;
+ }
+
#pragma warning disable CA1307 // Specify StringComparison for clarity
/// <summary>
/// Gets the type.
@@ -453,11 +558,16 @@ public sealed class BaseItemRepository
var images = item.ImageInfos.Select(e => Map(item.Id, e));
using var context = _dbProvider.CreateDbContext();
- using var transaction = context.Database.BeginTransaction();
+
+ if (!context.BaseItems.Any(bi => bi.Id == item.Id))
+ {
+ _logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
+ return;
+ }
+
context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete();
context.BaseItemImageInfos.AddRange(images);
context.SaveChanges();
- transaction.Commit();
}
/// <inheritdoc />
@@ -473,7 +583,7 @@ public sealed class BaseItemRepository
cancellationToken.ThrowIfCancellationRequested();
var tuples = new List<(BaseItemDto Item, List<Guid>? AncestorIds, BaseItemDto TopParent, IEnumerable<string> UserDataKey, List<string> InheritedTags)>();
- foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()))
+ foreach (var item in items.GroupBy(e => e.Id).Select(e => e.Last()).Where(e => e.Id != PlaceholderId))
{
var ancestorIds = item.SupportsAncestors ?
item.GetAncestorIds().Distinct().ToList() :
@@ -487,78 +597,140 @@ public sealed class BaseItemRepository
tuples.Add((item, ancestorIds, topParent, userdataKey, inheritedTags));
}
- var localItemValueCache = new Dictionary<(int MagicNumber, string Value), Guid>();
-
using var context = _dbProvider.CreateDbContext();
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).ToArray();
+ var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
+
foreach (var item in tuples)
{
var entity = Map(item.Item);
// TODO: refactor this "inconsistency"
entity.TopParentId = item.TopParent?.Id;
- if (!context.BaseItems.Any(e => e.Id == entity.Id))
+ if (!existingItems.Any(e => e == entity.Id))
{
context.BaseItems.Add(entity);
}
else
{
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
+ context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
+
+ if (entity.Images is { Count: > 0 })
+ {
+ context.BaseItemImageInfos.AddRange(entity.Images);
+ }
+
context.BaseItems.Attach(entity).State = EntityState.Modified;
}
+ }
- context.AncestorIds.Where(e => e.ItemId == entity.Id).ExecuteDelete();
- if (item.Item.SupportsAncestors && item.AncestorIds != null)
+ context.SaveChanges();
+
+ foreach (var item in newItems)
+ {
+ // reattach old userData entries
+ var userKeys = item.UserDataKey.ToArray();
+ var retentionDate = (DateTime?)null;
+ context.UserData
+ .Where(e => e.ItemId == PlaceholderId)
+ .Where(e => userKeys.Contains(e.CustomDataKey))
+ .ExecuteUpdate(e => e
+ .SetProperty(f => f.ItemId, item.Item.Id)
+ .SetProperty(f => f.RetentionDate, retentionDate));
+ }
+
+ var itemValueMaps = tuples
+ .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
+ .ToArray();
+ var allListedItemValues = itemValueMaps
+ .SelectMany(f => f.Values)
+ .Distinct()
+ .ToArray();
+ var existingValues = context.ItemValues
+ .Select(e => new
{
- foreach (var ancestorId in item.AncestorIds)
- {
- if (!context.BaseItems.Any(f => f.Id == ancestorId))
- {
- continue;
- }
+ item = e,
+ Key = e.Type + "+" + e.Value
+ })
+ .Where(f => allListedItemValues.Select(e => $"{(int)e.MagicNumber}+{e.Value}").Contains(f.Key))
+ .Select(e => e.item)
+ .ToArray();
+ var missingItemValues = allListedItemValues.Except(existingValues.Select(f => (MagicNumber: f.Type, f.Value))).Select(f => new ItemValue()
+ {
+ CleanValue = GetCleanValue(f.Value),
+ ItemValueId = Guid.NewGuid(),
+ Type = f.MagicNumber,
+ Value = f.Value
+ }).ToArray();
+ context.ItemValues.AddRange(missingItemValues);
+ context.SaveChanges();
+
+ var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
+ var valueMap = itemValueMaps
+ .Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray()))
+ .ToArray();
- context.AncestorIds.Add(new AncestorId()
+ var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
+
+ foreach (var item in valueMap)
+ {
+ var itemMappedValues = mappedValues.Where(e => e.ItemId == item.Item.Id).ToList();
+ foreach (var itemValue in item.Values)
+ {
+ var existingItem = itemMappedValues.FirstOrDefault(f => f.ItemValueId == itemValue.ItemValueId);
+ if (existingItem is null)
+ {
+ context.ItemValuesMap.Add(new ItemValueMap()
{
- ParentItemId = ancestorId,
- ItemId = entity.Id,
Item = null!,
- ParentItem = null!
+ ItemId = item.Item.Id,
+ ItemValue = null!,
+ ItemValueId = itemValue.ItemValueId
});
}
- }
-
- // Never save duplicate itemValues as they are now mapped anyway.
- var itemValuesToSave = GetItemValuesToSave(item.Item, item.InheritedTags).DistinctBy(e => (GetCleanValue(e.Value), e.MagicNumber));
- context.ItemValuesMap.Where(e => e.ItemId == entity.Id).ExecuteDelete();
- foreach (var itemValue in itemValuesToSave)
- {
- if (!localItemValueCache.TryGetValue(itemValue, out var refValue))
+ else
{
- refValue = context.ItemValues
- .Where(f => f.Value == itemValue.Value && (int)f.Type == itemValue.MagicNumber)
- .Select(e => e.ItemValueId)
- .FirstOrDefault();
+ // map exists, remove from list so its been handled.
+ itemMappedValues.Remove(existingItem);
}
+ }
+
+ // all still listed values are not in the new list so remove them.
+ context.ItemValuesMap.RemoveRange(itemMappedValues);
+ }
+
+ context.SaveChanges();
- if (refValue.IsEmpty())
+ foreach (var item in tuples)
+ {
+ if (item.Item.SupportsAncestors && item.AncestorIds != null)
+ {
+ var existingAncestorIds = context.AncestorIds.Where(e => e.ItemId == item.Item.Id).ToList();
+ var validAncestorIds = context.BaseItems.Where(e => item.AncestorIds.Contains(e.Id)).Select(f => f.Id).ToArray();
+ foreach (var ancestorId in validAncestorIds)
{
- context.ItemValues.Add(new ItemValue()
+ var existingAncestorId = existingAncestorIds.FirstOrDefault(e => e.ParentItemId == ancestorId);
+ if (existingAncestorId is null)
{
- CleanValue = GetCleanValue(itemValue.Value),
- Type = (ItemValueType)itemValue.MagicNumber,
- ItemValueId = refValue = Guid.NewGuid(),
- Value = itemValue.Value
- });
- localItemValueCache[itemValue] = refValue;
+ context.AncestorIds.Add(new AncestorId()
+ {
+ ParentItemId = ancestorId,
+ ItemId = item.Item.Id,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ else
+ {
+ existingAncestorIds.Remove(existingAncestorId);
+ }
}
- context.ItemValuesMap.Add(new ItemValueMap()
- {
- Item = null!,
- ItemId = entity.Id,
- ItemValue = null!,
- ItemValueId = refValue
- });
+ context.AncestorIds.RemoveRange(existingAncestorIds);
}
}
@@ -575,19 +747,26 @@ public sealed class BaseItemRepository
}
using var context = _dbProvider.CreateDbContext();
- var item = PrepareItemQuery(context, new()
+ var dbQuery = PrepareItemQuery(context, new()
{
DtoOptions = new()
{
EnableImages = true
}
- }).FirstOrDefault(e => e.Id == id);
+ });
+ dbQuery = dbQuery.Include(e => e.TrailerTypes)
+ .Include(e => e.Provider)
+ .Include(e => e.LockedFields)
+ .Include(e => e.UserData)
+ .Include(e => e.Images);
+
+ var item = dbQuery.FirstOrDefault(e => e.Id == id);
if (item is null)
{
return null;
}
- return DeserialiseBaseItem(item);
+ return DeserializeBaseItem(item);
}
/// <summary>
@@ -596,8 +775,9 @@ public sealed class BaseItemRepository
/// <param name="entity">The entity.</param>
/// <param name="dto">The dto base instance.</param>
/// <param name="appHost">The Application server Host.</param>
+ /// <param name="logger">The applogger.</param>
/// <returns>The dto to map.</returns>
- public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost)
+ public static BaseItemDto Map(BaseItemEntity entity, BaseItemDto dto, IServerApplicationHost? appHost, ILogger logger)
{
dto.Id = entity.Id;
dto.ParentId = entity.ParentId.GetValueOrDefault();
@@ -633,15 +813,17 @@ public sealed class BaseItemRepository
dto.TotalBitrate = entity.TotalBitrate;
dto.ExternalId = entity.ExternalId;
dto.Size = entity.Size;
- dto.Genres = entity.Genres?.Split('|') ?? [];
- dto.DateCreated = entity.DateCreated.GetValueOrDefault();
- dto.DateModified = entity.DateModified.GetValueOrDefault();
+ dto.Genres = string.IsNullOrWhiteSpace(entity.Genres) ? [] : entity.Genres.Split('|');
+ dto.DateCreated = entity.DateCreated ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
+ dto.DateModified = entity.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
dto.ChannelId = entity.ChannelId ?? Guid.Empty;
- dto.DateLastRefreshed = entity.DateLastRefreshed.GetValueOrDefault();
- dto.DateLastSaved = entity.DateLastSaved.GetValueOrDefault();
+ dto.DateLastRefreshed = entity.DateLastRefreshed ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
+ dto.DateLastSaved = entity.DateLastSaved ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
dto.OwnerId = string.IsNullOrWhiteSpace(entity.OwnerId) ? Guid.Empty : (Guid.TryParse(entity.OwnerId, out var ownerId) ? ownerId : Guid.Empty);
dto.Width = entity.Width.GetValueOrDefault();
dto.Height = entity.Height.GetValueOrDefault();
+ dto.UserData = entity.UserData;
+
if (entity.Provider is not null)
{
dto.ProviderIds = entity.Provider.ToDictionary(e => e.ProviderId, e => e.ProviderValue);
@@ -665,7 +847,7 @@ public sealed class BaseItemRepository
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
dto.Studios = entity.Studios?.Split('|') ?? [];
- dto.Tags = entity.Tags?.Split('|') ?? [];
+ dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
if (dto is IHasProgramAttributes hasProgramAttributes)
{
@@ -739,7 +921,7 @@ public sealed class BaseItemRepository
if (dto is Folder folder)
{
- folder.DateLastMediaAdded = entity.DateLastMediaAdded;
+ folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
return dto;
@@ -799,11 +981,11 @@ public sealed class BaseItemRepository
entity.ExternalId = dto.ExternalId;
entity.Size = dto.Size;
entity.Genres = string.Join('|', dto.Genres);
- entity.DateCreated = dto.DateCreated;
- entity.DateModified = dto.DateModified;
+ entity.DateCreated = dto.DateCreated == DateTime.MinValue ? null : dto.DateCreated;
+ entity.DateModified = dto.DateModified == DateTime.MinValue ? null : dto.DateModified;
entity.ChannelId = dto.ChannelId;
- entity.DateLastRefreshed = dto.DateLastRefreshed;
- entity.DateLastSaved = dto.DateLastSaved;
+ entity.DateLastRefreshed = dto.DateLastRefreshed == DateTime.MinValue ? null : dto.DateLastRefreshed;
+ entity.DateLastSaved = dto.DateLastSaved == DateTime.MinValue ? null : dto.DateLastSaved;
entity.OwnerId = dto.OwnerId.ToString();
entity.Width = dto.Width;
entity.Height = dto.Height;
@@ -913,7 +1095,7 @@ public sealed class BaseItemRepository
if (dto is Folder folder)
{
- entity.DateLastMediaAdded = folder.DateLastMediaAdded;
+ entity.DateLastMediaAdded = folder.DateLastMediaAdded == DateTime.MinValue ? null : folder.DateLastMediaAdded;
entity.IsFolder = folder.IsFolder;
}
@@ -949,7 +1131,7 @@ public sealed class BaseItemRepository
return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null;
}
- private BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
+ private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
{
ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity));
if (_serverConfigurationManager?.Configuration is null)
@@ -958,7 +1140,7 @@ public sealed class BaseItemRepository
}
var typeToSerialise = GetType(baseItemEntity.Type);
- return BaseItemRepository.DeserialiseBaseItem(
+ return BaseItemRepository.DeserializeBaseItem(
baseItemEntity,
_logger,
_appHost,
@@ -966,7 +1148,7 @@ public sealed class BaseItemRepository
}
/// <summary>
- /// Deserialises a BaseItemEntity and sets all properties.
+ /// Deserializes a BaseItemEntity and sets all properties.
/// </summary>
/// <param name="baseItemEntity">The DB entity.</param>
/// <param name="logger">Logger.</param>
@@ -974,9 +1156,9 @@ public sealed class BaseItemRepository
/// <param name="skipDeserialization">If only mapping should be processed.</param>
/// <returns>A mapped BaseItem.</returns>
/// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception>
- public static BaseItemDto DeserialiseBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
+ public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
{
- var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialise unknown type.");
+ var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
BaseItemDto? dto = null;
if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization)
{
@@ -992,13 +1174,13 @@ public sealed class BaseItemRepository
if (dto is null)
{
- dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialise unknown type.");
+ dto = Activator.CreateInstance(type) as BaseItemDto ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
}
- return Map(baseItemEntity, dto, appHost);
+ return Map(baseItemEntity, dto, appHost, logger);
}
- private QueryResult<(BaseItemDto Item, ItemCounts ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType)
+ private QueryResult<(BaseItemDto Item, ItemCounts? ItemCounts)> GetItemValues(InternalItemsQuery filter, IReadOnlyList<ItemValueType> itemValueTypes, string returnType)
{
ArgumentNullException.ThrowIfNull(filter);
@@ -1009,20 +1191,76 @@ public sealed class BaseItemRepository
using var context = _dbProvider.CreateDbContext();
- var query = TranslateQuery(context.BaseItems.AsNoTracking(), context, filter);
+ var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User)
+ {
+ ExcludeItemTypes = filter.ExcludeItemTypes,
+ IncludeItemTypes = filter.IncludeItemTypes,
+ MediaTypes = filter.MediaTypes,
+ AncestorIds = filter.AncestorIds,
+ ItemIds = filter.ItemIds,
+ TopParentIds = filter.TopParentIds,
+ ParentId = filter.ParentId,
+ IsAiring = filter.IsAiring,
+ IsMovie = filter.IsMovie,
+ IsSports = filter.IsSports,
+ IsKids = filter.IsKids,
+ IsNews = filter.IsNews,
+ IsSeries = filter.IsSeries
+ });
- query = query.Where(e => e.Type == returnType);
- // this does not seem to be nesseary but it does not make any sense why this isn't working.
- // && e.ItemValues!.Any(f => e.CleanName == f.ItemValue.CleanValue && itemValueTypes.Any(w => (ItemValueType)w == f.ItemValue.Type)));
+ var itemValuesQuery = context.ItemValues
+ .Where(f => itemValueTypes.Contains(f.Type))
+ .SelectMany(f => f.BaseItemsMap!, (f, w) => new { f, w })
+ .Join(
+ innerQueryFilter,
+ fw => fw.w.ItemId,
+ g => g.Id,
+ (fw, g) => fw.f.CleanValue);
+
+ var innerQuery = PrepareItemQuery(context, filter)
+ .Where(e => e.Type == returnType)
+ .Where(e => itemValuesQuery.Contains(e.CleanName));
+
+ var outerQueryFilter = new InternalItemsQuery(filter.User)
+ {
+ IsPlayed = filter.IsPlayed,
+ IsFavorite = filter.IsFavorite,
+ IsFavoriteOrLiked = filter.IsFavoriteOrLiked,
+ IsLiked = filter.IsLiked,
+ IsLocked = filter.IsLocked,
+ NameLessThan = filter.NameLessThan,
+ NameStartsWith = filter.NameStartsWith,
+ NameStartsWithOrGreater = filter.NameStartsWithOrGreater,
+ Tags = filter.Tags,
+ OfficialRatings = filter.OfficialRatings,
+ StudioIds = filter.StudioIds,
+ GenreIds = filter.GenreIds,
+ Genres = filter.Genres,
+ Years = filter.Years,
+ NameContains = filter.NameContains,
+ SearchTerm = filter.SearchTerm,
+ ExcludeItemIds = filter.ExcludeItemIds
+ };
- if (filter.OrderBy.Count != 0
- || !string.IsNullOrEmpty(filter.SearchTerm))
- {
- query = ApplyOrder(query, filter);
- }
- else
+ var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter)
+ .GroupBy(e => e.PresentationUniqueKey)
+ .Select(e => e.FirstOrDefault())
+ .Select(e => e!.Id);
+
+ var query = context.BaseItems
+ .Include(e => e.TrailerTypes)
+ .Include(e => e.Provider)
+ .Include(e => e.LockedFields)
+ .Include(e => e.Images)
+ .AsSingleQuery()
+ .Where(e => masterQuery.Contains(e.Id));
+
+ query = ApplyOrder(query, filter, context);
+
+ var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
+ if (filter.EnableTotalRecordCount)
{
- query = query.OrderBy(e => e.SortName);
+ result.TotalRecordCount = query.Count();
}
if (filter.Limit.HasValue || filter.StartIndex.HasValue)
@@ -1040,41 +1278,78 @@ public sealed class BaseItemRepository
}
}
- var result = new QueryResult<(BaseItemDto, ItemCounts)>();
- if (filter.EnableTotalRecordCount)
- {
- result.TotalRecordCount = query.GroupBy(e => e.PresentationUniqueKey).Select(e => e.First()).Count();
- }
-
- 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 audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
- var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
+ IQueryable<BaseItemEntity>? itemCountQuery = null;
- var resultQuery = query.Select(e => new
+ if (filter.IncludeItemTypes.Length > 0)
{
- item = e,
- // TODO: This is bad refactor!
- itemCount = new ItemCounts()
+ // if we are to include more then one type, sub query those items beforehand.
+
+ var typeSubQuery = new InternalItemsQuery(filter.User)
{
- SeriesCount = e.ItemValues!.Count(f => f.Item.Type == seriesTypeName),
- EpisodeCount = e.ItemValues!.Count(f => f.Item.Type == episodeTypeName),
- MovieCount = e.ItemValues!.Count(f => f.Item.Type == movieTypeName),
- AlbumCount = e.ItemValues!.Count(f => f.Item.Type == musicAlbumTypeName),
- ArtistCount = e.ItemValues!.Count(f => f.Item.Type == musicArtistTypeName),
- SongCount = e.ItemValues!.Count(f => f.Item.Type == audioTypeName),
- TrailerCount = e.ItemValues!.Count(f => f.Item.Type == trailerTypeName),
- }
- });
+ ExcludeItemTypes = filter.ExcludeItemTypes,
+ IncludeItemTypes = filter.IncludeItemTypes,
+ MediaTypes = filter.MediaTypes,
+ AncestorIds = filter.AncestorIds,
+ ExcludeItemIds = filter.ExcludeItemIds,
+ ItemIds = filter.ItemIds,
+ TopParentIds = filter.TopParentIds,
+ ParentId = filter.ParentId,
+ IsPlayed = filter.IsPlayed
+ };
- result.StartIndex = filter.StartIndex ?? 0;
- result.Items = resultQuery.ToArray().Where(e => e is not null).Select(e =>
+ itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
+ .Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
+
+ 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 audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
+ var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
+
+ var resultQuery = query.Select(e => new
+ {
+ item = e,
+ // TODO: This is bad refactor!
+ itemCount = new ItemCounts()
+ {
+ SeriesCount = itemCountQuery!.Count(f => f.Type == seriesTypeName),
+ EpisodeCount = itemCountQuery!.Count(f => f.Type == episodeTypeName),
+ MovieCount = itemCountQuery!.Count(f => f.Type == movieTypeName),
+ AlbumCount = itemCountQuery!.Count(f => f.Type == musicAlbumTypeName),
+ ArtistCount = itemCountQuery!.Count(f => f.Type == musicArtistTypeName),
+ SongCount = itemCountQuery!.Count(f => f.Type == audioTypeName),
+ TrailerCount = itemCountQuery!.Count(f => f.Type == trailerTypeName),
+ }
+ });
+
+ result.StartIndex = filter.StartIndex ?? 0;
+ result.Items =
+ [
+ .. resultQuery
+ .AsEnumerable()
+ .Where(e => e is not null)
+ .Select(e =>
+ {
+ return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount);
+ })
+ ];
+ }
+ else
{
- return (DeserialiseBaseItem(e.item, filter.SkipDeserialization), e.itemCount);
- }).ToArray();
+ result.StartIndex = filter.StartIndex ?? 0;
+ result.Items =
+ [
+ .. query
+ .AsEnumerable()
+ .Where(e => e is not null)
+ .Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e =>
+ {
+ return (DeserializeBaseItem(e, filter.SkipDeserialization), null);
+ })
+ ];
+ }
return result;
}
@@ -1102,27 +1377,27 @@ public sealed class BaseItemRepository
return value.RemoveDiacritics().ToLowerInvariant();
}
- private List<(int MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags)
+ private List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags)
{
- var list = new List<(int, string)>();
+ var list = new List<(ItemValueType, string)>();
if (item is IHasArtist hasArtist)
{
- list.AddRange(hasArtist.Artists.Select(i => (0, i)));
+ list.AddRange(hasArtist.Artists.Select(i => ((ItemValueType)0, i)));
}
if (item is IHasAlbumArtist hasAlbumArtist)
{
- list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (1, i)));
+ list.AddRange(hasAlbumArtist.AlbumArtists.Select(i => (ItemValueType.AlbumArtist, i)));
}
- list.AddRange(item.Genres.Select(i => (2, i)));
- list.AddRange(item.Studios.Select(i => (3, i)));
- list.AddRange(item.Tags.Select(i => (4, i)));
+ list.AddRange(item.Genres.Select(i => (ItemValueType.Genre, i)));
+ list.AddRange(item.Studios.Select(i => (ItemValueType.Studios, i)));
+ list.AddRange(item.Tags.Select(i => (ItemValueType.Tags, i)));
// keywords was 5
- list.AddRange(inheritedTags.Select(i => (6, i)));
+ list.AddRange(inheritedTags.Select(i => (ItemValueType.InheritedTags, i)));
// Remove all invalid values.
list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2));
@@ -1152,7 +1427,7 @@ public sealed class BaseItemRepository
{
Path = appHost?.ExpandVirtualPath(e.Path) ?? e.Path,
BlurHash = e.Blurhash is null ? null : Encoding.UTF8.GetString(e.Blurhash),
- DateModified = e.DateModified,
+ DateModified = e.DateModified ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc),
Height = e.Height,
Width = e.Width,
Type = (ImageType)e.ImageType
@@ -1246,7 +1521,7 @@ public sealed class BaseItemRepository
|| query.IncludeItemTypes.Contains(BaseItemKind.Season);
}
- private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter)
+ private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
{
var orderBy = filter.OrderBy;
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
@@ -1257,7 +1532,7 @@ public sealed class BaseItemRepository
}
else if (orderBy.Count == 0)
{
- return query;
+ return query.OrderBy(e => e.SortName);
}
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
@@ -1265,7 +1540,7 @@ public sealed class BaseItemRepository
var firstOrdering = orderBy.FirstOrDefault();
if (firstOrdering != default)
{
- var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter);
+ var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
if (firstOrdering.SortOrder == SortOrder.Ascending)
{
orderedQuery = query.OrderBy(expression);
@@ -1290,7 +1565,7 @@ public sealed class BaseItemRepository
foreach (var item in orderBy.Skip(1))
{
- var expression = OrderMapper.MapOrderByField(item.OrderBy, filter);
+ var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
if (item.SortOrder == SortOrder.Ascending)
{
orderedQuery = orderedQuery!.ThenBy(expression);
@@ -1356,7 +1631,7 @@ public sealed class BaseItemRepository
if (maxWidth.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Width >= maxWidth);
+ baseQuery = baseQuery.Where(e => e.Width <= maxWidth);
}
if (filter.MaxHeight.HasValue)
@@ -1429,8 +1704,17 @@ public sealed class BaseItemRepository
if (!string.IsNullOrEmpty(filter.SearchTerm))
{
- var searchTerm = filter.SearchTerm.ToLower();
- baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm)));
+ var cleanedSearchTerm = GetCleanValue(filter.SearchTerm);
+ var originalSearchTerm = filter.SearchTerm.ToLower();
+ if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f)))
+ {
+ cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%";
+ baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm)));
+ }
+ else
+ {
+ baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm)));
+ }
}
if (filter.IsFolder.HasValue)
@@ -1439,6 +1723,7 @@ public sealed class BaseItemRepository
}
var includeTypes = filter.IncludeItemTypes;
+
// Only specify excluded types if no included types are specified
if (filter.IncludeItemTypes.Length == 0)
{
@@ -1464,25 +1749,10 @@ public sealed class BaseItemRepository
baseQuery = baseQuery.Where(e => !excludeTypeName.Contains(e.Type));
}
}
- else if (includeTypes.Length == 1)
- {
- if (_itemTypeLookup.BaseItemKindNames.TryGetValue(includeTypes[0], out var includeTypeName))
- {
- baseQuery = baseQuery.Where(e => e.Type == includeTypeName);
- }
- }
- else if (includeTypes.Length > 1)
+ else
{
- var includeTypeName = new List<string>();
- foreach (var includeType in includeTypes)
- {
- if (_itemTypeLookup.BaseItemKindNames.TryGetValue(includeType, out var baseItemKindName))
- {
- includeTypeName.Add(baseItemKindName!);
- }
- }
-
- baseQuery = baseQuery.Where(e => includeTypeName.Contains(e.Type));
+ string[] types = includeTypes.Select(f => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(f)).Where(e => e != null).ToArray()!;
+ baseQuery = baseQuery.WhereOneOrMany(types, f => f.Type);
}
if (filter.ChannelIds.Count > 0)
@@ -1497,7 +1767,8 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Path))
{
- baseQuery = baseQuery.Where(e => e.Path == filter.Path);
+ var pathToQuery = GetPathToSave(filter.Path);
+ baseQuery = baseQuery.Where(e => e.Path == pathToQuery);
}
if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey))
@@ -1588,7 +1859,7 @@ public sealed class BaseItemRepository
if (filter.MinPremiereDate.HasValue)
{
- baseQuery = baseQuery.Where(e => e.PremiereDate <= filter.MinPremiereDate.Value);
+ baseQuery = baseQuery.Where(e => e.PremiereDate >= filter.MinPremiereDate.Value);
}
if (filter.MaxPremiereDate.HasValue)
@@ -1616,10 +1887,17 @@ public sealed class BaseItemRepository
if (filter.PersonIds.Length > 0)
{
+ var peopleEntityIds = context.BaseItems
+ .WhereOneOrMany(filter.PersonIds, b => b.Id)
+ .Join(
+ context.Peoples,
+ b => b.Name,
+ p => p.Name,
+ (b, p) => p.Id);
+
baseQuery = baseQuery
- .Where(e =>
- context.PeopleBaseItemMap.Where(w => context.BaseItems.Where(r => filter.PersonIds.Contains(r.Id)).Any(f => f.Name == w.People.Name))
- .Any(f => f.ItemId == e.Id));
+ .Where(e => context.PeopleBaseItemMap
+ .Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId)));
}
if (!string.IsNullOrWhiteSpace(filter.Person))
@@ -1655,26 +1933,35 @@ public sealed class BaseItemRepository
var nameContains = filter.NameContains;
if (!string.IsNullOrWhiteSpace(nameContains))
{
- baseQuery = baseQuery.Where(e =>
- e.CleanName!.Contains(nameContains)
- || e.OriginalTitle!.ToLower().Contains(nameContains!));
+ if (SearchWildcardTerms.Any(f => nameContains.Contains(f)))
+ {
+ nameContains = $"%{nameContains.Trim('%')}%";
+ baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName, nameContains) || EF.Functions.Like(e.OriginalTitle, nameContains));
+ }
+ else
+ {
+ baseQuery = baseQuery.Where(e =>
+ e.CleanName!.Contains(nameContains)
+ || e.OriginalTitle!.ToLower().Contains(nameContains!));
+ }
}
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
{
- baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith) || e.Name!.StartsWith(filter.NameStartsWith));
+ var startsWithLower = filter.NameStartsWith.ToLowerInvariant();
+ baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower));
}
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
{
- // i hate this
- baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]);
+ var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
+ baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0);
}
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
{
- // i hate this
- baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]);
+ var lessThanLower = filter.NameLessThan.ToLowerInvariant();
+ baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0);
}
if (filter.ImageTypes.Length > 0)
@@ -1706,7 +1993,7 @@ public sealed class BaseItemRepository
// We should probably figure this out for all folders, but for right now, this is the only place where we need it
if (filter.IncludeItemTypes.Length == 1 && filter.IncludeItemTypes[0] == BaseItemKind.Series)
{
- baseQuery = baseQuery.Where(e => context.BaseItems
+ baseQuery = baseQuery.Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId))
.Where(e => e.IsFolder == false && e.IsVirtualItem == false)
.Where(f => f.UserData!.FirstOrDefault(e => e.UserId == filter.User!.Id && e.Played)!.Played)
.Any(f => f.SeriesPresentationUniqueKey == e.PresentationUniqueKey) == filter.IsPlayed);
@@ -1740,64 +2027,70 @@ public sealed class BaseItemRepository
if (filter.ArtistIds.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type <= ItemValueType.Artist && filter.ArtistIds.Contains(f.ItemId)));
+ baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
}
if (filter.AlbumArtistIds.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.AlbumArtistIds.Contains(f.ItemId)));
+ baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.AlbumArtist, filter.AlbumArtistIds);
}
if (filter.ContributingArtistIds.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.ContributingArtistIds.Contains(f.ItemId)));
+ var contributingNames = context.BaseItems
+ .Where(b => filter.ContributingArtistIds.Contains(b.Id))
+ .Select(b => b.CleanName);
+
+ baseQuery = baseQuery.Where(e =>
+ e.ItemValues!.Any(ivm =>
+ ivm.ItemValue.Type == ItemValueType.Artist &&
+ contributingNames.Contains(ivm.ItemValue.CleanValue))
+ &&
+ !e.ItemValues!.Any(ivm =>
+ ivm.ItemValue.Type == ItemValueType.AlbumArtist &&
+ contributingNames.Contains(ivm.ItemValue.CleanValue)));
}
if (filter.AlbumIds.Length > 0)
{
- baseQuery = baseQuery.Where(e => context.BaseItems.Where(f => filter.AlbumIds.Contains(f.Id)).Any(f => f.Name == e.Album));
+ var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id);
+ baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album));
}
if (filter.ExcludeArtistIds.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Artist && filter.ExcludeArtistIds.Contains(f.ItemId)));
+ baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
}
if (filter.GenreIds.Count > 0)
{
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Genre && filter.GenreIds.Contains(f.ItemId)));
+ baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray());
}
if (filter.Genres.Count > 0)
{
- var cleanGenres = filter.Genres.Select(e => GetCleanValue(e)).ToArray();
+ var cleanGenres = filter.Genres.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue);
baseQuery = baseQuery
- .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Genre && cleanGenres.Contains(f.ItemValue.CleanValue)));
+ .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Genre).Any(cleanGenres));
}
if (tags.Count > 0)
{
- var cleanValues = tags.Select(e => GetCleanValue(e)).ToArray();
+ var cleanValues = tags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue);
baseQuery = baseQuery
- .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && cleanValues.Contains(f.ItemValue.CleanValue)));
+ .Where(e => e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues));
}
if (excludeTags.Count > 0)
{
- var cleanValues = excludeTags.Select(e => GetCleanValue(e)).ToArray();
+ var cleanValues = excludeTags.Select(e => GetCleanValue(e)).ToArray().OneOrManyExpressionBuilder<ItemValueMap, string>(f => f.ItemValue.CleanValue);
baseQuery = baseQuery
- .Where(e => !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && cleanValues.Contains(f.ItemValue.CleanValue)));
+ .Where(e => !e.ItemValues!.AsQueryable().Where(f => f.ItemValue.Type == ItemValueType.Tags).Any(cleanValues));
}
if (filter.StudioIds.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Studios && filter.StudioIds.Contains(f.ItemId)));
+ baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray());
}
if (filter.OfficialRatings.Length > 0)
@@ -1810,22 +2103,26 @@ public sealed class BaseItemRepository
if (filter.MinParentalRating != null)
{
var min = filter.MinParentalRating;
- minParentalRatingFilter = e => e.InheritedParentalRatingValue >= min.Score || e.InheritedParentalRatingValue == null;
- if (min.SubScore != null)
- {
- minParentalRatingFilter = minParentalRatingFilter.And(e => e.InheritedParentalRatingValue >= min.SubScore || e.InheritedParentalRatingValue == null);
- }
+ var minScore = min.Score;
+ var minSubScore = min.SubScore ?? 0;
+
+ minParentalRatingFilter = e =>
+ e.InheritedParentalRatingValue == null ||
+ e.InheritedParentalRatingValue > minScore ||
+ (e.InheritedParentalRatingValue == minScore && (e.InheritedParentalRatingSubValue ?? 0) >= minSubScore);
}
Expression<Func<BaseItemEntity, bool>>? maxParentalRatingFilter = null;
if (filter.MaxParentalRating != null)
{
var max = filter.MaxParentalRating;
- maxParentalRatingFilter = e => e.InheritedParentalRatingValue <= max.Score || e.InheritedParentalRatingValue == null;
- if (max.SubScore != null)
- {
- maxParentalRatingFilter = maxParentalRatingFilter.And(e => e.InheritedParentalRatingValue <= max.SubScore || e.InheritedParentalRatingValue == null);
- }
+ var maxScore = max.Score;
+ var maxSubScore = max.SubScore ?? 0;
+
+ maxParentalRatingFilter = e =>
+ e.InheritedParentalRatingValue == null ||
+ e.InheritedParentalRatingValue < maxScore ||
+ (e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore);
}
if (filter.HasParentalRating ?? false)
@@ -1961,7 +2258,7 @@ public sealed class BaseItemRepository
if (filter.HasDeadParentId.HasValue && filter.HasDeadParentId.Value)
{
baseQuery = baseQuery
- .Where(e => e.ParentId.HasValue && !context.BaseItems.Any(f => f.Id == e.ParentId.Value));
+ .Where(e => e.ParentId.HasValue && !context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Any(f => f.Id == e.ParentId.Value));
}
if (filter.IsDeadArtist.HasValue && filter.IsDeadArtist.Value)
@@ -1988,15 +2285,9 @@ public sealed class BaseItemRepository
.Where(e => !context.Peoples.Any(f => f.Name == e.Name));
}
- if (filter.Years.Length == 1)
- {
- baseQuery = baseQuery
- .Where(e => e.ProductionYear == filter.Years[0]);
- }
- else if (filter.Years.Length > 1)
+ if (filter.Years.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => filter.Years.Any(f => f == e.ProductionYear));
+ baseQuery = baseQuery.WhereOneOrMany(filter.Years, e => e.ProductionYear!.Value);
}
var isVirtualItem = filter.IsVirtualItem ?? filter.IsMissing;
@@ -2037,45 +2328,61 @@ public sealed class BaseItemRepository
if (filter.MediaTypes.Length > 0)
{
var mediaTypes = filter.MediaTypes.Select(f => f.ToString()).ToArray();
- baseQuery = baseQuery
- .Where(e => mediaTypes.Contains(e.MediaType));
+ baseQuery = baseQuery.WhereOneOrMany(mediaTypes, e => e.MediaType);
}
if (filter.ItemIds.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => filter.ItemIds.Contains(e.Id));
+ baseQuery = baseQuery.WhereOneOrMany(filter.ItemIds, e => e.Id);
}
if (filter.ExcludeItemIds.Length > 0)
{
baseQuery = baseQuery
- .Where(e => !filter.ItemIds.Contains(e.Id));
+ .Where(e => !filter.ExcludeItemIds.Contains(e.Id));
}
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
{
- baseQuery = baseQuery.Where(e => !e.Provider!.All(f => !filter.ExcludeProviderIds.All(w => f.ProviderId == w.Key && f.ProviderValue == w.Value)));
+ var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray();
+ baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f)));
}
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => !filter.HasAnyProviderId.Any(w => f.ProviderId == w.Key && f.ProviderValue == w.Value)));
+ // Allow setting a null or empty value to get all items that have the specified provider set.
+ var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray();
+ if (includeAny.Length > 0)
+ {
+ baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId)));
+ }
+
+ var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray();
+ if (includeSelected.Length > 0)
+ {
+ baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f)));
+ }
}
if (filter.HasImdbId.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb"));
+ baseQuery = filter.HasImdbId.Value
+ ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower()))
+ : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower()));
}
if (filter.HasTmdbId.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb"));
+ baseQuery = filter.HasTmdbId.Value
+ ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower()))
+ : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower()));
}
if (filter.HasTvdbId.HasValue)
{
- baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb"));
+ baseQuery = filter.HasTvdbId.Value
+ ? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower()))
+ : baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower()));
}
var queryTopParentIds = filter.TopParentIds;
@@ -2090,19 +2397,19 @@ public sealed class BaseItemRepository
}
else
{
- baseQuery = baseQuery.Where(e => queryTopParentIds.Contains(e.TopParentId!.Value));
+ baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
}
}
if (filter.AncestorIds.Length > 0)
{
- baseQuery = baseQuery.Where(e => e.Children!.Any(f => filter.AncestorIds.Contains(f.ParentItemId)));
+ baseQuery = baseQuery.Where(e => e.Parents!.Any(f => filter.AncestorIds.Contains(f.ParentItemId)));
}
if (!string.IsNullOrWhiteSpace(filter.AncestorWithPresentationUniqueKey))
{
baseQuery = baseQuery
- .Where(e => context.BaseItems.Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id)));
+ .Where(e => context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)).Where(f => f.PresentationUniqueKey == filter.AncestorWithPresentationUniqueKey).Any(f => f.Children!.Any(w => w.ItemId == e.Id)));
}
if (!string.IsNullOrWhiteSpace(filter.SeriesPresentationUniqueKey))
@@ -2113,40 +2420,34 @@ public sealed class BaseItemRepository
if (filter.ExcludeInheritedTags.Length > 0)
{
- baseQuery = baseQuery
- .Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags)
- .Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)));
+ baseQuery = baseQuery.Where(e =>
+ !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))
+ && (e.Type != _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode] || !e.SeriesId.HasValue ||
+ !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))));
}
if (filter.IncludeInheritedTags.Length > 0)
{
- // Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client.
- // In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well.
- if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode)
+ // For seasons and episodes, we also need to check the parent series' tags.
+ if (includeTypes.Any(t => t == BaseItemKind.Episode || t == BaseItemKind.Season))
{
- baseQuery = baseQuery
- .Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags)
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
- ||
- (e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value)!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags)
- .Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
+ baseQuery = baseQuery.Where(e =>
+ e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
+ || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
}
// A playlist should be accessible to its owner regardless of allowed tags.
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
{
- baseQuery = baseQuery
- .Where(e =>
- e.Parents!
- .Any(f =>
- f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue))
- || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
+ baseQuery = baseQuery.Where(e =>
+ e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
+ || e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
// d ^^ this is stupid it hate this.
}
else
{
- baseQuery = baseQuery
- .Where(e => e.Parents!.Any(f => f.ParentItem.ItemValues!.Any(w => w.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(w.ItemValue.CleanValue))));
+ baseQuery = baseQuery.Where(e =>
+ e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
}
}
@@ -2229,4 +2530,78 @@ public sealed class BaseItemRepository
return baseQuery;
}
+
+ /// <inheritdoc/>
+ public async Task<bool> ItemExistsAsync(Guid id)
+ {
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ return await dbContext.BaseItems.AnyAsync(f => f.Id == id).ConfigureAwait(false);
+ }
+ }
+
+ /// <inheritdoc/>
+ public bool GetIsPlayed(User user, Guid id, bool recursive)
+ {
+ using var dbContext = _dbProvider.CreateDbContext();
+
+ if (recursive)
+ {
+ var folderList = TraverseHirachyDown(id, dbContext, item => (item.IsFolder || item.IsVirtualItem));
+
+ return dbContext.BaseItems
+ .Where(e => folderList.Contains(e.ParentId!.Value) && !e.IsFolder && !e.IsVirtualItem)
+ .All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ }
+
+ return dbContext.BaseItems.Where(e => e.ParentId == id).All(f => f.UserData!.Any(e => e.UserId == user.Id && e.Played));
+ }
+
+ private static HashSet<Guid> TraverseHirachyDown(Guid parentId, JellyfinDbContext dbContext, Expression<Func<BaseItemEntity, bool>>? filter = null)
+ {
+ var folderStack = new HashSet<Guid>()
+ {
+ parentId
+ };
+ var folderList = new HashSet<Guid>()
+ {
+ parentId
+ };
+
+ while (folderStack.Count != 0)
+ {
+ var items = folderStack.ToArray();
+ folderStack.Clear();
+ var query = dbContext.BaseItems
+ .WhereOneOrMany(items, e => e.ParentId!.Value);
+
+ if (filter != null)
+ {
+ query = query.Where(filter);
+ }
+
+ foreach (var item in query.Select(e => e.Id).ToArray())
+ {
+ if (folderList.Add(item))
+ {
+ folderStack.Add(item);
+ }
+ }
+ }
+
+ return folderList;
+ }
+
+ /// <inheritdoc/>
+ public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames)
+ {
+ using var dbContext = _dbProvider.CreateDbContext();
+
+ var artists = dbContext.BaseItems.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
+ .Where(e => artistNames.Contains(e.Name))
+ .ToArray();
+
+ return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray());
+ }
}
diff --git a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs
index 93e15735c..98700f322 100644
--- a/Jellyfin.Server.Implementations/Item/ChapterRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/ChapterRepository.cs
@@ -1,12 +1,12 @@
using System;
using System.Collections.Generic;
-using System.Collections.Immutable;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
-using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Drawing;
-using MediaBrowser.Model.Dto;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Entities;
using Microsoft.EntityFrameworkCore;
@@ -31,19 +31,7 @@ public class ChapterRepository : IChapterRepository
_imageProcessor = imageProcessor;
}
- /// <inheritdoc cref="IChapterRepository"/>
- public ChapterInfo? GetChapter(BaseItemDto baseItem, int index)
- {
- return GetChapter(baseItem.Id, index);
- }
-
- /// <inheritdoc cref="IChapterRepository"/>
- public IReadOnlyList<ChapterInfo> GetChapters(BaseItemDto baseItem)
- {
- return GetChapters(baseItem.Id);
- }
-
- /// <inheritdoc cref="IChapterRepository"/>
+ /// <inheritdoc />
public ChapterInfo? GetChapter(Guid baseItemId, int index)
{
using var context = _dbProvider.CreateDbContext();
@@ -62,7 +50,7 @@ public class ChapterRepository : IChapterRepository
return null;
}
- /// <inheritdoc cref="IChapterRepository"/>
+ /// <inheritdoc />
public IReadOnlyList<ChapterInfo> GetChapters(Guid baseItemId)
{
using var context = _dbProvider.CreateDbContext();
@@ -77,7 +65,7 @@ public class ChapterRepository : IChapterRepository
.ToArray();
}
- /// <inheritdoc cref="IChapterRepository"/>
+ /// <inheritdoc />
public void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters)
{
using var context = _dbProvider.CreateDbContext();
@@ -95,6 +83,17 @@ public class ChapterRepository : IChapterRepository
}
}
+ /// <inheritdoc />
+ public async Task DeleteChaptersAsync(Guid itemId, CancellationToken cancellationToken)
+ {
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ await dbContext.Chapters.Where(c => c.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
private Chapter Map(ChapterInfo chapterInfo, int index, Guid itemId)
{
return new Chapter()
@@ -118,7 +117,12 @@ public class ChapterRepository : IChapterRepository
ImagePath = chapterInfo.ImagePath,
Name = chapterInfo.Name,
};
- chapterEntity.ImageTag = _imageProcessor.GetImageCacheTag(baseItemPath, chapterEntity.ImageDateModified);
+
+ if (!string.IsNullOrEmpty(chapterInfo.ImagePath))
+ {
+ chapterEntity.ImageTag = _imageProcessor.GetImageCacheTag(baseItemPath, chapterEntity.ImageDateModified);
+ }
+
return chapterEntity;
}
}
diff --git a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs
index a2267700f..438458c6b 100644
--- a/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/KeyframeRepository.cs
@@ -55,10 +55,21 @@ public class KeyframeRepository : IKeyframeRepository
public async Task SaveKeyframeDataAsync(Guid itemId, MediaEncoding.Keyframes.KeyframeData data, CancellationToken cancellationToken)
{
using var context = _dbProvider.CreateDbContext();
- using var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ var transaction = await context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+ await using (transaction.ConfigureAwait(false))
+ {
+ await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ /// <inheritdoc />
+ public async Task DeleteKeyframeDataAsync(Guid itemId, CancellationToken cancellationToken)
+ {
+ using var context = _dbProvider.CreateDbContext();
await context.KeyframeData.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
- await context.KeyframeData.AddAsync(Map(data, itemId), cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
- await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
}
diff --git a/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs b/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs
index 3ae6dbd70..e75dda439 100644
--- a/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/MediaAttachmentRepository.cs
@@ -25,8 +25,16 @@ public class MediaAttachmentRepository(IDbContextFactory<JellyfinDbContext> dbPr
{
using var context = dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
+
+ // Users may replace a media with a version that includes attachments to one without them.
+ // So when saving attachments is triggered by a library scan, we always unconditionally
+ // clear the old ones, and then add the new ones if given.
context.AttachmentStreamInfos.Where(e => e.ItemId.Equals(id)).ExecuteDelete();
- context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id)));
+ if (attachments.Any())
+ {
+ context.AttachmentStreamInfos.AddRange(attachments.Select(e => Map(e, id)));
+ }
+
context.SaveChanges();
transaction.Commit();
}
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index 03249b927..192ee7499 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -1,7 +1,10 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
using System;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using Microsoft.EntityFrameworkCore;
@@ -18,39 +21,50 @@ public static class OrderMapper
/// </summary>
/// <param name="sortBy">Item property to sort by.</param>
/// <param name="query">Context Query.</param>
+ /// <param name="jellyfinDbContext">Context.</param>
/// <returns>Func to be executed later for sorting query.</returns>
- public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query)
+ public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
{
- return sortBy switch
+ return (sortBy, query.User) switch
{
- ItemSortBy.AirTime => e => e.SortName, // TODO
- ItemSortBy.Runtime => e => e.RunTimeTicks,
- ItemSortBy.Random => e => EF.Functions.Random(),
- ItemSortBy.DatePlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
- ItemSortBy.PlayCount => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
- ItemSortBy.IsFavoriteOrLiked => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
- ItemSortBy.IsFolder => e => e.IsFolder,
- ItemSortBy.IsPlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
- ItemSortBy.IsUnplayed => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
- ItemSortBy.DateLastContentAdded => e => e.DateLastMediaAdded,
- ItemSortBy.Artist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
- ItemSortBy.AlbumArtist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
- ItemSortBy.Studio => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
- ItemSortBy.OfficialRating => e => e.InheritedParentalRatingValue,
- // ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)",
- ItemSortBy.SeriesSortName => e => e.SeriesName,
+ (ItemSortBy.AirTime, _) => e => e.SortName, // TODO
+ (ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
+ (ItemSortBy.Random, _) => e => EF.Functions.Random(),
+ (ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
+ (ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
+ (ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
+ (ItemSortBy.IsFolder, _) => e => e.IsFolder,
+ (ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
+ (ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
+ (ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded,
+ (ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
+ (ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
+ (ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
+ (ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
+ (ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
+ (ItemSortBy.Album, _) => e => e.Album,
+ (ItemSortBy.DateCreated, _) => e => e.DateCreated,
+ (ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
+ (ItemSortBy.StartDate, _) => e => e.StartDate,
+ (ItemSortBy.Name, _) => e => e.CleanName,
+ (ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
+ (ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
+ (ItemSortBy.CriticRating, _) => e => e.CriticRating,
+ (ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate,
+ (ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber,
+ (ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
+ (ItemSortBy.SeriesDatePlayed, not null) => e =>
+ jellyfinDbContext.BaseItems
+ .Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
+ .Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
+ .Max(f => f),
+ (ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
+ .Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
+ .Max(f => f),
+ // ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
+ // .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played)
+ // .Max(f => f.LastPlayedDate),
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
- ItemSortBy.Album => e => e.Album,
- ItemSortBy.DateCreated => e => e.DateCreated,
- ItemSortBy.PremiereDate => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
- ItemSortBy.StartDate => e => e.StartDate,
- ItemSortBy.Name => e => e.Name,
- ItemSortBy.CommunityRating => e => e.CommunityRating,
- ItemSortBy.ProductionYear => e => e.ProductionYear,
- ItemSortBy.CriticRating => e => e.CriticRating,
- ItemSortBy.VideoBitRate => e => e.TotalBitrate,
- ItemSortBy.ParentIndexNumber => e => e.ParentIndexNumber,
- ItemSortBy.IndexNumber => e => e.IndexNumber,
_ => e => e.SortName
};
}
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index 77877835e..355ed6479 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -5,6 +5,7 @@ using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Entities.Libraries;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Persistence;
@@ -34,16 +35,22 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
using var context = _dbProvider.CreateDbContext();
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
- // dbQuery = dbQuery.OrderBy(e => e.ListOrder);
- if (filter.Limit > 0)
+ // Include PeopleBaseItemMap
+ if (!filter.ItemId.IsEmpty())
{
- dbQuery = dbQuery.Take(filter.Limit);
+ dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
+ .OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder)
+ .ThenBy(e => e.PersonType)
+ .ThenBy(e => e.Name);
+ }
+ else
+ {
+ dbQuery = dbQuery.OrderBy(e => e.Name);
}
- // Include PeopleBaseItemMap
- if (!filter.ItemId.IsEmpty())
+ if (filter.Limit > 0)
{
- dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId));
+ dbQuery = dbQuery.Take(filter.Limit);
}
return dbQuery.AsEnumerable().Select(Map).ToArray();
@@ -53,7 +60,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter)
{
using var context = _dbProvider.CreateDbContext();
- var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
+ var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
// dbQuery = dbQuery.OrderBy(e => e.ListOrder);
if (filter.Limit > 0)
@@ -61,39 +68,81 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
dbQuery = dbQuery.Take(filter.Limit);
}
- return dbQuery.Select(e => e.Name).ToArray();
+ return dbQuery.ToArray();
}
/// <inheritdoc />
public void UpdatePeople(Guid itemId, IReadOnlyList<PersonInfo> people)
{
+ foreach (var item in people.Where(e => e.Role is null))
+ {
+ item.Role = string.Empty;
+ }
+
+ // multiple metadata providers can provide the _same_ person
+ people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray();
+ var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray();
+
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
+ var existingPersons = context.Peoples.Select(e => new
+ {
+ item = e,
+ SelectionKey = e.Name + "-" + e.PersonType
+ })
+ .Where(p => personKeys.Contains(p.SelectionKey))
+ .Select(f => f.item)
+ .ToArray();
+
+ var toAdd = people
+ .Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
+ .Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
+ .Select(Map);
+ context.Peoples.AddRange(toAdd);
+ context.SaveChanges();
+
+ var personsEntities = toAdd.Concat(existingPersons).ToArray();
+
+ var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
+
+ var listOrder = 0;
- context.PeopleBaseItemMap.Where(e => e.ItemId == itemId).ExecuteDelete();
- // TODO: yes for __SOME__ reason there can be duplicates.
- foreach (var item in people.DistinctBy(e => e.Id))
+ foreach (var person in people)
{
- var personEntity = Map(item);
- var existingEntity = context.Peoples.FirstOrDefault(e => e.Id == personEntity.Id);
- if (existingEntity is null)
+ if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist)
{
- context.Peoples.Add(personEntity);
- existingEntity = personEntity;
+ continue;
}
- context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
+ var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString());
+ var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
+ if (existingMap is null)
{
- Item = null!,
- ItemId = itemId,
- People = existingEntity,
- PeopleId = existingEntity.Id,
- ListOrder = item.SortOrder,
- SortOrder = item.SortOrder,
- Role = item.Role
- });
+ context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
+ {
+ Item = null!,
+ ItemId = itemId,
+ People = null!,
+ PeopleId = entityPerson.Id,
+ ListOrder = listOrder,
+ SortOrder = person.SortOrder,
+ Role = person.Role
+ });
+ }
+ else
+ {
+ // Update the order for existing mappings
+ existingMap.ListOrder = listOrder;
+ existingMap.SortOrder = person.SortOrder;
+ // person mapping already exists so remove from list
+ existingMaps.Remove(existingMap);
+ }
+
+ listOrder++;
}
+ context.PeopleBaseItemMap.RemoveRange(existingMaps);
+
context.SaveChanges();
transaction.Commit();
}
@@ -133,9 +182,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.User is not null && filter.IsFavorite.HasValue)
{
var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
- query = query.Where(e => e.PersonType == personType)
- .Where(e => context.BaseItems.Where(d => d.UserData!.Any(w => w.IsFavorite == filter.IsFavorite && w.UserId.Equals(filter.User.Id)))
- .Select(f => f.Name).Contains(e.Name));
+ var oldQuery = query;
+
+ query = context.UserData
+ .Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))
+ .Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person)
+ .Distinct()
+ .AsNoTracking();
}
if (!filter.ItemId.IsEmpty())
diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs
index d6eeafacc..d00c87463 100644
--- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs
+++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs
@@ -10,12 +10,12 @@ using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
-using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.MediaSegments;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -30,7 +30,6 @@ public class MediaSegmentManager : IMediaSegmentManager
private readonly ILogger<MediaSegmentManager> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IMediaSegmentProvider[] _segmentProviders;
- private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="MediaSegmentManager"/> class.
@@ -38,12 +37,10 @@ public class MediaSegmentManager : IMediaSegmentManager
/// <param name="logger">Logger.</param>
/// <param name="dbProvider">EFCore Database factory.</param>
/// <param name="segmentProviders">List of all media segment providers.</param>
- /// <param name="libraryManager">Library manager.</param>
public MediaSegmentManager(
ILogger<MediaSegmentManager> logger,
IDbContextFactory<JellyfinDbContext> dbProvider,
- IEnumerable<IMediaSegmentProvider> segmentProviders,
- ILibraryManager libraryManager)
+ IEnumerable<IMediaSegmentProvider> segmentProviders)
{
_logger = logger;
_dbProvider = dbProvider;
@@ -51,13 +48,11 @@ public class MediaSegmentManager : IMediaSegmentManager
_segmentProviders = segmentProviders
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
.ToArray();
- _libraryManager = libraryManager;
}
/// <inheritdoc/>
- public async Task RunSegmentPluginProviders(BaseItem baseItem, bool overwrite, CancellationToken cancellationToken)
+ public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken)
{
- var libraryOptions = _libraryManager.GetLibraryOptions(baseItem);
var providers = _segmentProviders
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
.OrderBy(i =>
@@ -73,50 +68,88 @@ public class MediaSegmentManager : IMediaSegmentManager
return;
}
- using var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
-
- if (!overwrite && (await db.MediaSegments.AnyAsync(e => e.ItemId.Equals(baseItem.Id), cancellationToken).ConfigureAwait(false)))
+ var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (db.ConfigureAwait(false))
{
- _logger.LogDebug("Skip {MediaPath} as it already contains media segments", baseItem.Path);
- return;
- }
-
- _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count);
-
- await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
-
- // no need to recreate the request object every time.
- var requestItem = new MediaSegmentGenerationRequest() { ItemId = baseItem.Id };
+ _logger.LogDebug("Start media segment extraction for {MediaPath} with {CountProviders} providers enabled", baseItem.Path, providers.Count);
- foreach (var provider in providers)
- {
- if (!await provider.Supports(baseItem).ConfigureAwait(false))
+ if (forceOverwrite)
{
- _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path);
- continue;
+ // delete all existing media segments if forceOverwrite is set.
+ await db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
- try
+ foreach (var provider in providers)
{
- var segments = await provider.GetMediaSegments(requestItem, cancellationToken)
- .ConfigureAwait(false);
- if (segments.Count == 0)
+ if (!await provider.Supports(baseItem).ConfigureAwait(false))
{
- _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path);
+ _logger.LogDebug("Media Segment provider {ProviderName} does not support item with path {MediaPath}", provider.Name, baseItem.Path);
continue;
}
- _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path);
- var providerId = GetProviderId(provider.Name);
- foreach (var segment in segments)
+ IQueryable<MediaSegment> existingSegments;
+ if (forceOverwrite)
{
- segment.ItemId = baseItem.Id;
- await CreateSegmentAsync(segment, providerId).ConfigureAwait(false);
+ existingSegments = Array.Empty<MediaSegment>().AsQueryable();
+ }
+ else
+ {
+ existingSegments = db.MediaSegments.Where(e => e.ItemId.Equals(baseItem.Id) && e.SegmentProviderId == GetProviderId(provider.Name));
+ }
+
+ var requestItem = new MediaSegmentGenerationRequest()
+ {
+ ItemId = baseItem.Id,
+ ExistingSegments = existingSegments.Select(e => Map(e)).ToArray()
+ };
+
+ try
+ {
+ var segments = await provider.GetMediaSegments(requestItem, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!forceOverwrite)
+ {
+ var existingSegmentsList = existingSegments.ToArray(); // Cannot use requestItem's list, as the provider might tamper with its items.
+ if (segments.Count == requestItem.ExistingSegments.Count && segments.All(e => existingSegmentsList.Any(f =>
+ {
+ return
+ e.StartTicks == f.StartTicks &&
+ e.EndTicks == f.EndTicks &&
+ e.Type == f.Type;
+ })))
+ {
+ _logger.LogDebug("Media Segment provider {ProviderName} did not modify any segments for {MediaPath}", provider.Name, baseItem.Path);
+ continue;
+ }
+
+ // delete existing media segments that were re-generated.
+ await existingSegments.ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ if (segments.Count == 0 && !requestItem.ExistingSegments.Any())
+ {
+ _logger.LogDebug("Media Segment provider {ProviderName} did not find any segments for {MediaPath}", provider.Name, baseItem.Path);
+ continue;
+ }
+ else if (segments.Count == 0 && requestItem.ExistingSegments.Any())
+ {
+ _logger.LogDebug("Media Segment provider {ProviderName} deleted all segments for {MediaPath}", provider.Name, baseItem.Path);
+ continue;
+ }
+
+ _logger.LogInformation("Media Segment provider {ProviderName} found {CountSegments} for {MediaPath}", provider.Name, segments.Count, baseItem.Path);
+ var providerId = GetProviderId(provider.Name);
+ foreach (var segment in segments)
+ {
+ segment.ItemId = baseItem.Id;
+ await CreateSegmentAsync(segment, providerId).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path);
}
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Provider {ProviderName} failed to extract segments from {MediaPath}", provider.Name, baseItem.Path);
}
}
}
@@ -126,67 +159,77 @@ public class MediaSegmentManager : IMediaSegmentManager
{
ArgumentOutOfRangeException.ThrowIfLessThan(mediaSegment.EndTicks, mediaSegment.StartTicks);
- using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
- db.MediaSegments.Add(Map(mediaSegment, segmentProviderId));
- await db.SaveChangesAsync().ConfigureAwait(false);
+ var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (db.ConfigureAwait(false))
+ {
+ db.MediaSegments.Add(Map(mediaSegment, segmentProviderId));
+ await db.SaveChangesAsync().ConfigureAwait(false);
+ }
+
return mediaSegment;
}
/// <inheritdoc />
public async Task DeleteSegmentAsync(Guid segmentId)
{
- using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
- await db.MediaSegments.Where(e => e.Id.Equals(segmentId)).ExecuteDeleteAsync().ConfigureAwait(false);
+ var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (db.ConfigureAwait(false))
+ {
+ await db.MediaSegments.Where(e => e.Id.Equals(segmentId)).ExecuteDeleteAsync().ConfigureAwait(false);
+ }
}
/// <inheritdoc />
- public async Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(Guid itemId, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true)
+ public async Task DeleteSegmentsAsync(Guid itemId, CancellationToken cancellationToken)
{
- var baseItem = _libraryManager.GetItemById(itemId);
-
- if (baseItem is null)
+ var db = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (db.ConfigureAwait(false))
{
- _logger.LogError("Tried to request segments for an invalid item");
- return [];
+ await db.MediaSegments.Where(e => e.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
}
-
- return await GetSegmentsAsync(baseItem, typeFilter, filterByProvider).ConfigureAwait(false);
}
/// <inheritdoc />
- public async Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem item, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true)
+ public async Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem? item, IEnumerable<MediaSegmentType>? typeFilter, LibraryOptions libraryOptions, bool filterByProvider = true)
{
- using var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
-
- var query = db.MediaSegments
- .Where(e => e.ItemId.Equals(item.Id));
-
- if (typeFilter is not null)
+ if (item is null)
{
- query = query.Where(e => typeFilter.Contains(e.Type));
+ _logger.LogError("Tried to request segments for an invalid item");
+ return [];
}
- if (filterByProvider)
+ var db = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (db.ConfigureAwait(false))
{
- var libraryOptions = _libraryManager.GetLibraryOptions(item);
- var providerIds = _segmentProviders
- .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
- .Select(f => GetProviderId(f.Name))
- .ToArray();
- if (providerIds.Length == 0)
+ var query = db.MediaSegments
+ .Where(e => e.ItemId.Equals(item.Id));
+
+ if (typeFilter is not null)
{
- return [];
+ query = query.Where(e => typeFilter.Contains(e.Type));
}
- query = query.Where(e => providerIds.Contains(e.SegmentProviderId));
- }
+ if (filterByProvider)
+ {
+ var providerIds = _segmentProviders
+ .Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
+ .Select(f => GetProviderId(f.Name))
+ .ToArray();
+ if (providerIds.Length == 0)
+ {
+ return [];
+ }
- return query
- .OrderBy(e => e.StartTicks)
- .AsNoTracking()
- .AsEnumerable()
- .Select(Map)
- .ToArray();
+ query = query.Where(e => providerIds.Contains(e.SegmentProviderId));
+ }
+
+ return query
+ .OrderBy(e => e.StartTicks)
+ .AsNoTracking()
+ .AsEnumerable()
+ .Select(Map)
+ .ToArray();
+ }
}
private static MediaSegmentDto Map(MediaSegment segment)
diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
index 635644179..ce628a04d 100644
--- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
+++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs
@@ -13,8 +13,7 @@ namespace Jellyfin.Server.Implementations.StorageHelpers;
public static class StorageHelper
{
private const long TwoGigabyte = 2_147_483_647L;
- private const long FiveHundredAndTwelveMegaByte = 536_870_911L;
- private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
+ private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
/// <summary>
/// Tests the available storage capacity on the jellyfin paths with estimated minimum values.
@@ -24,10 +23,8 @@ public static class StorageHelper
public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger)
{
TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte);
- TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte);
TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte);
- TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte);
}
/// <summary>
@@ -72,10 +69,12 @@ public static class StorageHelper
private static void TestDataDirectorySize(string path, ILogger logger, long threshold = -1)
{
logger.LogDebug("Check path {TestPath} for storage capacity", path);
+ Directory.CreateDirectory(path);
+
var drive = new DriveInfo(path);
if (threshold != -1 && drive.AvailableFreeSpace < threshold)
{
- throw new InvalidOperationException($"The path `{path}` has insufficient free space. Required: at least {HumanizeStorageSize(threshold)}.");
+ throw new InvalidOperationException($"The path `{path}` has insufficient free space. Available: {HumanizeStorageSize(drive.AvailableFreeSpace)}, Required: {HumanizeStorageSize(threshold)}.");
}
logger.LogInformation(
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index bf39f13a7..4505a377c 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -7,6 +7,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
+using J2N.Collections.Generic.Extensions;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Common.Configuration;
@@ -14,7 +15,6 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Configuration;
@@ -34,7 +34,6 @@ public class TrickplayManager : ITrickplayManager
private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem;
private readonly EncodingHelper _encodingHelper;
- private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _config;
private readonly IImageEncoder _imageEncoder;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
@@ -51,7 +50,6 @@ public class TrickplayManager : ITrickplayManager
/// <param name="mediaEncoder">The media encoder.</param>
/// <param name="fileSystem">The file system.</param>
/// <param name="encodingHelper">The encoding helper.</param>
- /// <param name="libraryManager">The library manager.</param>
/// <param name="config">The server configuration manager.</param>
/// <param name="imageEncoder">The image encoder.</param>
/// <param name="dbProvider">The database provider.</param>
@@ -62,7 +60,6 @@ public class TrickplayManager : ITrickplayManager
IMediaEncoder mediaEncoder,
IFileSystem fileSystem,
EncodingHelper encodingHelper,
- ILibraryManager libraryManager,
IServerConfigurationManager config,
IImageEncoder imageEncoder,
IDbContextFactory<JellyfinDbContext> dbProvider,
@@ -73,7 +70,6 @@ public class TrickplayManager : ITrickplayManager
_mediaEncoder = mediaEncoder;
_fileSystem = fileSystem;
_encodingHelper = encodingHelper;
- _libraryManager = libraryManager;
_config = config;
_imageEncoder = imageEncoder;
_dbProvider = dbProvider;
@@ -82,10 +78,10 @@ public class TrickplayManager : ITrickplayManager
}
/// <inheritdoc />
- public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken)
+ public async Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{
var options = _config.Configuration.TrickplayOptions;
- if (!CanGenerateTrickplay(video, options.Interval))
+ if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction || !CanGenerateTrickplay(video, options.Interval))
{
return;
}
@@ -97,28 +93,28 @@ public class TrickplayManager : ITrickplayManager
var existingResolution = resolution.Key;
var tileWidth = resolution.Value.TileWidth;
var tileHeight = resolution.Value.TileHeight;
- var shouldBeSavedWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia;
- var localOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false);
- var mediaOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true);
- if (shouldBeSavedWithMedia && Directory.Exists(localOutputDir))
+ var shouldBeSavedWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia;
+ var localOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false));
+ var mediaOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true));
+ if (shouldBeSavedWithMedia && localOutputDir.Exists)
{
- var localDirFiles = Directory.GetFiles(localOutputDir);
- var mediaDirExists = Directory.Exists(mediaOutputDir);
- if (localDirFiles.Length > 0 && ((mediaDirExists && Directory.GetFiles(mediaOutputDir).Length == 0) || !mediaDirExists))
+ var localDirFiles = localOutputDir.EnumerateFiles();
+ var mediaDirExists = mediaOutputDir.Exists;
+ if (localDirFiles.Any() && ((mediaDirExists && mediaOutputDir.EnumerateFiles().Any()) || !mediaDirExists))
{
// Move images from local dir to media dir
- MoveContent(localOutputDir, mediaOutputDir);
+ MoveContent(localOutputDir.FullName, mediaOutputDir.FullName);
_logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutputDir);
}
}
- else if (!shouldBeSavedWithMedia && Directory.Exists(mediaOutputDir))
+ else if (!shouldBeSavedWithMedia && mediaOutputDir.Exists)
{
- var mediaDirFiles = Directory.GetFiles(mediaOutputDir);
- var localDirExists = Directory.Exists(localOutputDir);
- if (mediaDirFiles.Length > 0 && ((localDirExists && Directory.GetFiles(localOutputDir).Length == 0) || !localDirExists))
+ var mediaDirFiles = mediaOutputDir.EnumerateFiles();
+ var localDirExists = localOutputDir.Exists;
+ if (mediaDirFiles.Any() && ((localDirExists && localOutputDir.EnumerateFiles().Any()) || !localDirExists))
{
// Move images from media dir to local dir
- MoveContent(mediaOutputDir, localOutputDir);
+ MoveContent(mediaOutputDir.FullName, localOutputDir.FullName);
_logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutputDir);
}
}
@@ -131,36 +127,98 @@ public class TrickplayManager : ITrickplayManager
var parent = Directory.GetParent(sourceFolder);
if (parent is not null)
{
- var parentContent = Directory.GetDirectories(parent.FullName);
- if (parentContent.Length == 0)
+ var parentContent = parent.EnumerateDirectories();
+ if (!parentContent.Any())
{
- Directory.Delete(parent.FullName);
+ parent.Delete();
}
}
}
/// <inheritdoc />
- public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken)
+ public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{
- _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
-
var options = _config.Configuration.TrickplayOptions;
- if (options.Interval < 1000)
+ if (!CanGenerateTrickplay(video, options.Interval) || libraryOptions is null)
{
- _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value of 1000", options.Interval);
- options.Interval = 1000;
+ return;
}
- foreach (var width in options.WidthResolutions)
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
{
- cancellationToken.ThrowIfCancellationRequested();
- await RefreshTrickplayDataInternal(
- video,
- replace,
- width,
- options,
- libraryOptions,
- cancellationToken).ConfigureAwait(false);
+ var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
+ var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
+ if (!libraryOptions.EnableTrickplayImageExtraction || replace)
+ {
+ // Prune existing data
+ if (Directory.Exists(trickplayDirectory))
+ {
+ try
+ {
+ Directory.Delete(trickplayDirectory, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Unable to clear trickplay directory: {Directory}: {Exception}", trickplayDirectory, ex);
+ }
+ }
+
+ await dbContext.TrickplayInfos
+ .Where(i => i.ItemId.Equals(video.Id))
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!replace)
+ {
+ return;
+ }
+ }
+
+ _logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
+
+ if (options.Interval < 1000)
+ {
+ _logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value of 1000", options.Interval);
+ options.Interval = 1000;
+ }
+
+ foreach (var width in options.WidthResolutions)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await RefreshTrickplayDataInternal(
+ video,
+ replace,
+ width,
+ options,
+ saveWithMedia,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ // Cleanup old trickplay files
+ if (Directory.Exists(trickplayDirectory))
+ {
+ var existingFolders = Directory.GetDirectories(trickplayDirectory).ToList();
+ var trickplayInfos = await dbContext.TrickplayInfos
+ .AsNoTracking()
+ .Where(i => i.ItemId.Equals(video.Id))
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+ var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)).ToList();
+ var foldersToRemove = existingFolders.Except(expectedFolders);
+ foreach (var folder in foldersToRemove)
+ {
+ try
+ {
+ _logger.LogWarning("Pruning trickplay files for {Item}", video.Path);
+ Directory.Delete(folder, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Unable to remove trickplay directory: {Directory}: {Exception}", folder, ex);
+ }
+ }
+ }
}
}
@@ -169,14 +227,9 @@ public class TrickplayManager : ITrickplayManager
bool replace,
int width,
TrickplayOptions options,
- LibraryOptions? libraryOptions,
+ bool saveWithMedia,
CancellationToken cancellationToken)
{
- if (!CanGenerateTrickplay(video, options.Interval))
- {
- return;
- }
-
var imgTempDir = string.Empty;
using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
@@ -201,10 +254,10 @@ public class TrickplayManager : ITrickplayManager
}
// We support video backdrops, but we should not generate trickplay images for them
- var parentDirectory = Directory.GetParent(mediaPath);
+ var parentDirectory = Directory.GetParent(video.Path);
if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase))
{
- _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id);
+ _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
return;
}
@@ -220,13 +273,12 @@ public class TrickplayManager : ITrickplayManager
var tileWidth = options.TileWidth;
var tileHeight = options.TileHeight;
- var saveWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia;
- var outputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia);
+ var outputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia));
// Import existing trickplay tiles
- if (!replace && Directory.Exists(outputDir))
+ if (!replace && outputDir.Exists)
{
- var existingFiles = Directory.GetFiles(outputDir);
+ var existingFiles = outputDir.GetFiles();
if (existingFiles.Length > 0)
{
var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureAwait(false);
@@ -251,9 +303,9 @@ public class TrickplayManager : ITrickplayManager
foreach (var tile in existingFiles)
{
- var image = _imageEncoder.GetImageSize(tile);
+ var image = _imageEncoder.GetImageSize(tile.FullName);
localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)image.Height / localTrickplayInfo.TileHeight));
- var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tile).Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000));
+ var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000));
localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate);
}
@@ -296,7 +348,7 @@ public class TrickplayManager : ITrickplayManager
.ToList();
// Create tiles
- var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir);
+ var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir.FullName);
// Save tiles info
try
@@ -319,7 +371,7 @@ public class TrickplayManager : ITrickplayManager
// Make sure no files stay in metadata folders on failure
// if tiles info wasn't saved.
- Directory.Delete(outputDir, true);
+ outputDir.Delete(true);
}
}
catch (Exception ex)
@@ -435,12 +487,6 @@ public class TrickplayManager : ITrickplayManager
return false;
}
- var libraryOptions = _libraryManager.GetLibraryOptions(video);
- if (libraryOptions is null || !libraryOptions.EnableTrickplayImageExtraction)
- {
- return false;
- }
-
// Can't extract images if there are no video streams
return video.GetMediaStreams().Count > 0;
}
@@ -507,6 +553,13 @@ public class TrickplayManager : ITrickplayManager
}
/// <inheritdoc />
+ public async Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken)
+ {
+ var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await dbContext.TrickplayInfos.Where(i => i.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ /// <inheritdoc />
public async Task<Dictionary<string, Dictionary<int, TrickplayInfo>>> GetTrickplayManifest(BaseItem item)
{
var trickplayManifest = new Dictionary<string, Dictionary<int, TrickplayInfo>>();
diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
index 35c43b176..446849b6f 100644
--- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
@@ -59,7 +59,7 @@ namespace Jellyfin.Server.Implementations.Users
}
// As long as jellyfin supports password-less users, we need this little block here to accommodate
- if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password))
+ if (string.IsNullOrEmpty(resolvedUser.Password) && string.IsNullOrEmpty(password))
{
return Task.FromResult(new ProviderAuthenticationResult
{
@@ -94,10 +94,6 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public bool HasPassword(User user)
- => !string.IsNullOrEmpty(user?.Password);
-
- /// <inheritdoc />
public Task ChangePassword(User user, string newPassword)
{
if (string.IsNullOrEmpty(newPassword))
diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
index 6296881a9..49a9fda94 100644
--- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
@@ -92,33 +93,38 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork)
+ public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork)
{
- byte[] bytes = new byte[4];
- RandomNumberGenerator.Fill(bytes);
- string pin = BitConverter.ToString(bytes);
-
DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
- string filePath = _passwordResetFileBase + user.Id + ".json";
- SerializablePasswordReset spr = new SerializablePasswordReset
- {
- ExpirationDate = expireTime,
- Pin = pin,
- PinFile = filePath,
- UserName = user.Username
- };
+ var usernameHash = enteredUsername.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ var pinFile = _passwordResetFileBase + usernameHash + ".json";
- FileStream fileStream = AsyncFile.OpenWrite(filePath);
- await using (fileStream.ConfigureAwait(false))
+ if (user is not null && isInNetwork)
{
- await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
+ byte[] bytes = new byte[4];
+ RandomNumberGenerator.Fill(bytes);
+ string pin = BitConverter.ToString(bytes);
+
+ SerializablePasswordReset spr = new SerializablePasswordReset
+ {
+ ExpirationDate = expireTime,
+ Pin = pin,
+ PinFile = pinFile,
+ UserName = user.Username
+ };
+
+ FileStream fileStream = AsyncFile.Create(pinFile);
+ await using (fileStream.ConfigureAwait(false))
+ {
+ await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
+ }
}
return new ForgotPasswordResult
{
Action = ForgotPasswordAction.PinCode,
PinExpirationDate = expireTime,
- PinFile = filePath
+ PinFile = pinFile
};
}
diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
index 0f21e11a3..0e126fe9a 100644
--- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
+++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
@@ -1,109 +1,116 @@
-#pragma warning disable CA1307
-#pragma warning disable CA1309
-
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller;
using Microsoft.EntityFrameworkCore;
-namespace Jellyfin.Server.Implementations.Users
+namespace Jellyfin.Server.Implementations.Users;
+
+/// <summary>
+/// Manages the storage and retrieval of display preferences through Entity Framework.
+/// </summary>
+public sealed class DisplayPreferencesManager : IDisplayPreferencesManager
{
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+
/// <summary>
- /// Manages the storage and retrieval of display preferences through Entity Framework.
+ /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class.
/// </summary>
- public sealed class DisplayPreferencesManager : IDisplayPreferencesManager, IAsyncDisposable
+ /// <param name="dbContextFactory">The database context factory.</param>
+ public DisplayPreferencesManager(IDbContextFactory<JellyfinDbContext> dbContextFactory)
+ {
+ _dbContextFactory = dbContextFactory;
+ }
+
+ /// <inheritdoc />
+ public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client)
{
- private readonly JellyfinDbContext _dbContext;
+ using var dbContext = _dbContextFactory.CreateDbContext();
+ var prefs = dbContext.DisplayPreferences
+ .Include(pref => pref.HomeSections)
+ .FirstOrDefault(pref =>
+ pref.UserId.Equals(userId) && pref.Client == client && pref.ItemId.Equals(itemId));
- /// <summary>
- /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class.
- /// </summary>
- /// <param name="dbContextFactory">The database context factory.</param>
- public DisplayPreferencesManager(IDbContextFactory<JellyfinDbContext> dbContextFactory)
+ if (prefs is null)
{
- _dbContext = dbContextFactory.CreateDbContext();
+ prefs = new DisplayPreferences(userId, itemId, client);
+ dbContext.DisplayPreferences.Add(prefs);
+ dbContext.SaveChanges();
}
- /// <inheritdoc />
- public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client)
+ return prefs;
+ }
+
+ /// <inheritdoc />
+ public ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client)
+ {
+ using var dbContext = _dbContextFactory.CreateDbContext();
+ var prefs = dbContext.ItemDisplayPreferences
+ .FirstOrDefault(pref => pref.UserId.Equals(userId) && pref.ItemId.Equals(itemId) && pref.Client == client);
+
+ if (prefs is null)
{
- var prefs = _dbContext.DisplayPreferences
- .Include(pref => pref.HomeSections)
- .FirstOrDefault(pref =>
- pref.UserId.Equals(userId) && string.Equals(pref.Client, client) && pref.ItemId.Equals(itemId));
-
- if (prefs is null)
- {
- prefs = new DisplayPreferences(userId, itemId, client);
- _dbContext.DisplayPreferences.Add(prefs);
- }
-
- return prefs;
+ prefs = new ItemDisplayPreferences(userId, Guid.Empty, client);
+ dbContext.ItemDisplayPreferences.Add(prefs);
+ dbContext.SaveChanges();
}
- /// <inheritdoc />
- public ItemDisplayPreferences GetItemDisplayPreferences(Guid userId, Guid itemId, string client)
- {
- var prefs = _dbContext.ItemDisplayPreferences
- .FirstOrDefault(pref => pref.UserId.Equals(userId) && pref.ItemId.Equals(itemId) && string.Equals(pref.Client, client));
+ return prefs;
+ }
- if (prefs is null)
- {
- prefs = new ItemDisplayPreferences(userId, Guid.Empty, client);
- _dbContext.ItemDisplayPreferences.Add(prefs);
- }
+ /// <inheritdoc />
+ public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client)
+ {
+ using var dbContext = _dbContextFactory.CreateDbContext();
+ return dbContext.ItemDisplayPreferences
+ .Where(prefs => prefs.UserId.Equals(userId) && !prefs.ItemId.Equals(default) && prefs.Client == client)
+ .ToList();
+ }
- return prefs;
- }
+ /// <inheritdoc />
+ public Dictionary<string, string?> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client)
+ {
+ using var dbContext = _dbContextFactory.CreateDbContext();
+ return dbContext.CustomItemDisplayPreferences
+ .Where(prefs => prefs.UserId.Equals(userId)
+ && prefs.ItemId.Equals(itemId)
+ && prefs.Client == client)
+ .ToDictionary(prefs => prefs.Key, prefs => prefs.Value);
+ }
- /// <inheritdoc />
- public IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client)
- {
- return _dbContext.ItemDisplayPreferences
- .Where(prefs => prefs.UserId.Equals(userId) && !prefs.ItemId.Equals(default) && string.Equals(prefs.Client, client))
- .ToList();
- }
+ /// <inheritdoc />
+ public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences)
+ {
+ using var dbContext = _dbContextFactory.CreateDbContext();
+ dbContext.CustomItemDisplayPreferences.Where(prefs => prefs.UserId.Equals(userId)
+ && prefs.ItemId.Equals(itemId)
+ && prefs.Client == client)
+ .ExecuteDelete();
- /// <inheritdoc />
- public Dictionary<string, string?> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client)
+ foreach (var (key, value) in customPreferences)
{
- return _dbContext.CustomItemDisplayPreferences
- .Where(prefs => prefs.UserId.Equals(userId)
- && prefs.ItemId.Equals(itemId)
- && string.Equals(prefs.Client, client))
- .ToDictionary(prefs => prefs.Key, prefs => prefs.Value);
+ dbContext.CustomItemDisplayPreferences
+ .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value));
}
- /// <inheritdoc />
- public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences)
- {
- var existingPrefs = _dbContext.CustomItemDisplayPreferences
- .Where(prefs => prefs.UserId.Equals(userId)
- && prefs.ItemId.Equals(itemId)
- && string.Equals(prefs.Client, client));
- _dbContext.CustomItemDisplayPreferences.RemoveRange(existingPrefs);
-
- foreach (var (key, value) in customPreferences)
- {
- _dbContext.CustomItemDisplayPreferences
- .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value));
- }
- }
+ dbContext.SaveChanges();
+ }
- /// <inheritdoc />
- public void SaveChanges()
- {
- _dbContext.SaveChanges();
- }
+ /// <inheritdoc/>
+ public void UpdateDisplayPreferences(DisplayPreferences displayPreferences)
+ {
+ using var dbContext = _dbContextFactory.CreateDbContext();
+ dbContext.DisplayPreferences.Attach(displayPreferences).State = EntityState.Modified;
+ dbContext.SaveChanges();
+ }
- /// <inheritdoc />
- public async ValueTask DisposeAsync()
- {
- await _dbContext.DisposeAsync().ConfigureAwait(false);
- }
+ /// <inheritdoc/>
+ public void UpdateItemDisplayPreferences(ItemDisplayPreferences itemDisplayPreferences)
+ {
+ using var dbContext = _dbContextFactory.CreateDbContext();
+ dbContext.ItemDisplayPreferences.Attach(itemDisplayPreferences).State = EntityState.Modified;
+ dbContext.SaveChanges();
}
}
diff --git a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
index caf9d5bd9..56b8a7fc4 100644
--- a/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/InvalidAuthProvider.cs
@@ -22,12 +22,6 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public bool HasPassword(User user)
- {
- return true;
- }
-
- /// <inheritdoc />
public Task ChangePassword(User user, string newPassword)
{
return Task.CompletedTask;
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 3dfb14d71..63d962ad9 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -272,6 +272,7 @@ namespace Jellyfin.Server.Implementations.Users
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
+ dbContext.Users.Attach(user);
dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
@@ -305,15 +306,12 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public UserDto GetUserDto(User user, string? remoteEndPoint = null)
{
- var hasPassword = GetAuthenticationProvider(user).HasPassword(user);
var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications;
return new UserDto
{
Name = user.Username,
Id = user.Id,
ServerId = _appHost.SystemId,
- HasPassword = hasPassword,
- HasConfiguredPassword = hasPassword,
EnableAutoLogin = user.EnableAutoLogin,
LastLoginDate = user.LastLoginDate,
LastActivityDate = user.LastActivityDate,
@@ -507,23 +505,18 @@ namespace Jellyfin.Server.Implementations.Users
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
{
var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername);
+ var passwordResetProvider = GetPasswordResetProvider(user);
+
+ var result = await passwordResetProvider
+ .StartForgotPasswordProcess(user, enteredUsername, isInNetwork)
+ .ConfigureAwait(false);
if (user is not null && isInNetwork)
{
- var passwordResetProvider = GetPasswordResetProvider(user);
- var result = await passwordResetProvider
- .StartForgotPasswordProcess(user, isInNetwork)
- .ConfigureAwait(false);
-
await UpdateUserAsync(user).ConfigureAwait(false);
- return result;
}
- return new ForgotPasswordResult
- {
- Action = ForgotPasswordAction.InNetworkRequired,
- PinFile = string.Empty
- };
+ return result;
}
/// <inheritdoc/>
@@ -759,8 +752,13 @@ namespace Jellyfin.Server.Implementations.Users
return GetAuthenticationProviders(user)[0];
}
- private IPasswordResetProvider GetPasswordResetProvider(User user)
+ private IPasswordResetProvider GetPasswordResetProvider(User? user)
{
+ if (user is null)
+ {
+ return _defaultPasswordResetProvider;
+ }
+
return GetPasswordResetProviders(user)[0];
}
@@ -887,7 +885,8 @@ namespace Jellyfin.Server.Implementations.Users
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{
- dbContext.Users.Update(user);
+ dbContext.Users.Attach(user);
+ dbContext.Entry(user).State = EntityState.Modified;
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs
index f3bf6b805..2548ddea7 100644
--- a/Jellyfin.Server/CoreAppHost.cs
+++ b/Jellyfin.Server/CoreAppHost.cs
@@ -84,7 +84,7 @@ namespace Jellyfin.Server
serviceCollection.AddSingleton<IAuthenticationProvider, DefaultAuthenticationProvider>();
serviceCollection.AddSingleton<IAuthenticationProvider, InvalidAuthProvider>();
serviceCollection.AddSingleton<IPasswordResetProvider, DefaultPasswordResetProvider>();
- serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>();
+ serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
serviceCollection.AddSingleton<IDeviceManager, DeviceManager>();
serviceCollection.AddSingleton<ITrickplayManager, TrickplayManager>();
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index 6066893de..a56baba33 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -69,16 +69,6 @@ namespace Jellyfin.Server.Extensions
}
/// <summary>
- /// Adds LAN based access filtering to the application pipeline.
- /// </summary>
- /// <param name="appBuilder">The application builder.</param>
- /// <returns>The updated application builder.</returns>
- public static IApplicationBuilder UseLanFiltering(this IApplicationBuilder appBuilder)
- {
- return appBuilder.UseMiddleware<LanFilteringMiddleware>();
- }
-
- /// <summary>
/// Enables url decoding before binding to the application pipeline.
/// </summary>
/// <param name="appBuilder">The <see cref="IApplicationBuilder"/>.</param>
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index b04e55baa..08c1a5065 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -116,26 +116,7 @@ namespace Jellyfin.Server.Extensions
.AddTransient<ICorsPolicyProvider, CorsPolicyProvider>()
.Configure<ForwardedHeadersOptions>(options =>
{
- // https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs
- // Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues.
-
- if (config.KnownProxies.Length == 0)
- {
- options.ForwardedHeaders = ForwardedHeaders.None;
- options.KnownNetworks.Clear();
- options.KnownProxies.Clear();
- }
- else
- {
- options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
- AddProxyAddresses(config, config.KnownProxies, options);
- }
-
- // Only set forward limit if we have some known proxies or some known networks.
- if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0)
- {
- options.ForwardLimit = null;
- }
+ ConfigureForwardHeaders(config, options);
})
.AddMvc(opts =>
{
@@ -183,6 +164,30 @@ namespace Jellyfin.Server.Extensions
return mvcBuilder.AddControllersAsServices();
}
+ internal static void ConfigureForwardHeaders(NetworkConfiguration config, ForwardedHeadersOptions options)
+ {
+ // https://github.com/dotnet/aspnetcore/blob/master/src/Middleware/HttpOverrides/src/ForwardedHeadersMiddleware.cs
+ // Enable debug logging on Microsoft.AspNetCore.HttpOverrides.ForwardedHeadersMiddleware to help investigate issues.
+
+ if (config.KnownProxies.Length == 0)
+ {
+ options.ForwardedHeaders = ForwardedHeaders.None;
+ options.KnownNetworks.Clear();
+ options.KnownProxies.Clear();
+ }
+ else
+ {
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;
+ AddProxyAddresses(config, config.KnownProxies, options);
+ }
+
+ // Only set forward limit if we have some known proxies or some known networks.
+ if (options.KnownProxies.Count != 0 || options.KnownNetworks.Count != 0)
+ {
+ options.ForwardLimit = null;
+ }
+ }
+
/// <summary>
/// Adds Swagger to the service collection.
/// </summary>
@@ -215,7 +220,7 @@ namespace Jellyfin.Server.Extensions
});
// Add all xml doc files to swagger generator.
- var xmlFiles = Directory.GetFiles(
+ var xmlFiles = Directory.EnumerateFiles(
AppContext.BaseDirectory,
"*.xml",
SearchOption.TopDirectoryOnly);
@@ -248,7 +253,7 @@ namespace Jellyfin.Server.Extensions
c.AddSwaggerTypeMappings();
c.SchemaFilter<IgnoreEnumSchemaFilter>();
- c.OperationFilter<RetryOnTemporarlyUnavailableFilter>();
+ c.OperationFilter<RetryOnTemporarilyUnavailableFilter>();
c.OperationFilter<SecurityRequirementsOperationFilter>();
c.OperationFilter<FileResponseFilter>();
c.OperationFilter<FileRequestFilter>();
diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs
index 421eeecda..58d37db5a 100644
--- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs
+++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs
@@ -175,7 +175,7 @@ namespace Jellyfin.Server.Filters
// Manually generate sync play GroupUpdate messages.
var groupUpdateTypes = typeof(GroupUpdate<>).Assembly.GetTypes()
- .Where(t => t.BaseType != null
+ .Where(t => t.BaseType is not null
&& t.BaseType.IsGenericType
&& t.BaseType.GetGenericTypeDefinition() == typeof(GroupUpdate<>))
.ToList();
diff --git a/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs
new file mode 100644
index 000000000..8b7268513
--- /dev/null
+++ b/Jellyfin.Server/Filters/RetryOnTemporarilyUnavailableFilter.cs
@@ -0,0 +1,51 @@
+using System.Collections.Generic;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Jellyfin.Server.Filters;
+
+internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
+{
+ public void Apply(OpenApiOperation operation, OperationFilterContext context)
+ {
+ operation.Responses.TryAdd(
+ "503",
+ new OpenApiResponse
+ {
+ Description = "The server is currently starting or is temporarily not available.",
+ Headers = new Dictionary<string, OpenApiHeader>
+ {
+ {
+ "Retry-After", new OpenApiHeader
+ {
+ AllowEmptyValue = true,
+ Required = false,
+ Description = "A hint for when to retry the operation in full seconds.",
+ Schema = new OpenApiSchema
+ {
+ Type = "integer",
+ Format = "int32"
+ }
+ }
+ },
+ {
+ "Message", new OpenApiHeader
+ {
+ AllowEmptyValue = true,
+ Required = false,
+ Description = "A short plain-text reason why the server is not available.",
+ Schema = new OpenApiSchema
+ {
+ Type = "string",
+ Format = "text"
+ }
+ }
+ }
+ },
+ Content = new Dictionary<string, OpenApiMediaType>()
+ {
+ { "text/html", new OpenApiMediaType() }
+ }
+ });
+ }
+}
diff --git a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs b/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs
deleted file mode 100644
index 74470eda0..000000000
--- a/Jellyfin.Server/Filters/RetryOnTemporarlyUnavailableFilter.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Net.Http.Headers;
-using Microsoft.OpenApi.Models;
-using Swashbuckle.AspNetCore.SwaggerGen;
-
-namespace Jellyfin.Server.Filters;
-
-internal class RetryOnTemporarlyUnavailableFilter : IOperationFilter
-{
- public void Apply(OpenApiOperation operation, OperationFilterContext context)
- {
- operation.Responses.Add("503", new OpenApiResponse()
- {
- Description = "The server is currently starting or is temporarly not available.",
- Headers = new Dictionary<string, OpenApiHeader>()
- {
- {
- "Retry-After",
- new() { AllowEmptyValue = true, Required = false, Description = "A hint for when to retry the operation in full seconds." }
- },
- {
- "Message",
- new() { AllowEmptyValue = true, Required = false, Description = "A short plain-text reason why the server is not available." }
- }
- },
- Content = new Dictionary<string, OpenApiMediaType>()
- {
- {
- "text/html",
- new OpenApiMediaType()
- }
- }
- });
- }
-}
diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
index 401392a63..8f5757269 100644
--- a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
+++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
@@ -66,15 +66,8 @@ public class SecurityRequirementsOperationFilter : IOperationFilter
return;
}
- if (!operation.Responses.ContainsKey("401"))
- {
- operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" });
- }
-
- if (!operation.Responses.ContainsKey("403"))
- {
- operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" });
- }
+ operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
+ operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });
var scheme = new OpenApiSecurityScheme
{
diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs
index bbf6d31f1..93c996166 100644
--- a/Jellyfin.Server/Helpers/StartupHelpers.cs
+++ b/Jellyfin.Server/Helpers/StartupHelpers.cs
@@ -3,18 +3,19 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
-using System.Net;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Threading.Tasks;
using Emby.Server.Implementations;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Extensions;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Serilog;
+using Serilog.Extensions.Logging;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace Jellyfin.Server.Helpers;
@@ -257,11 +258,14 @@ public static class StartupHelpers
{
try
{
+ var startupLogger = new LoggerProviderCollection();
+ startupLogger.AddProvider(new SetupServer.SetupLoggerFactory());
// Serilog.Log is used by SerilogLoggerFactory when no logger is specified
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.Enrich.FromLogContext()
.Enrich.WithThreadId()
+ .WriteTo.Async(e => e.Providers(startupLogger))
.CreateLogger();
}
catch (Exception ex)
diff --git a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs b/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs
deleted file mode 100644
index 910b5c467..000000000
--- a/Jellyfin.Server/Infrastructure/SymlinkFollowingPhysicalFileResultExecutor.cs
+++ /dev/null
@@ -1,151 +0,0 @@
-// The MIT License (MIT)
-//
-// Copyright (c) .NET Foundation and Contributors
-//
-// All rights reserved.
-//
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-//
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-//
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Http.Extensions;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Infrastructure;
-using Microsoft.Extensions.Logging;
-using Microsoft.Net.Http.Headers;
-
-namespace Jellyfin.Server.Infrastructure
-{
- /// <inheritdoc />
- public class SymlinkFollowingPhysicalFileResultExecutor : PhysicalFileResultExecutor
- {
- /// <summary>
- /// Initializes a new instance of the <see cref="SymlinkFollowingPhysicalFileResultExecutor"/> class.
- /// </summary>
- /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param>
- public SymlinkFollowingPhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(loggerFactory)
- {
- }
-
- /// <inheritdoc />
- protected override FileMetadata GetFileInfo(string path)
- {
- var fileInfo = new FileInfo(path);
- var length = fileInfo.Length;
- // This may or may not be fixed in .NET 6, but looks like it will not https://github.com/dotnet/aspnetcore/issues/34371
- if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
- {
- using var fileHandle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
- length = RandomAccess.GetLength(fileHandle);
- }
-
- return new FileMetadata
- {
- Exists = fileInfo.Exists,
- Length = length,
- LastModified = fileInfo.LastWriteTimeUtc
- };
- }
-
- /// <inheritdoc />
- protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength)
- {
- ArgumentNullException.ThrowIfNull(context);
- ArgumentNullException.ThrowIfNull(result);
-
- if (range is not null && rangeLength == 0)
- {
- return;
- }
-
- // It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code
- if (!IsSymLink(result.FileName))
- {
- await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false);
- return;
- }
-
- var response = context.HttpContext.Response;
-
- if (range is not null)
- {
- await SendFileAsync(
- result.FileName,
- response,
- offset: range.From ?? 0L,
- count: rangeLength).ConfigureAwait(false);
- return;
- }
-
- await SendFileAsync(
- result.FileName,
- response,
- offset: 0,
- count: null).ConfigureAwait(false);
- }
-
- private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default)
- {
- var fileInfo = GetFileInfo(filePath);
- if (offset < 0 || offset > fileInfo.Length)
- {
- throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty);
- }
-
- if (count.HasValue
- && (count.Value < 0 || count.Value > fileInfo.Length - offset))
- {
- throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty);
- }
-
- // Copied from SendFileFallback.SendFileAsync
- const int BufferSize = 1024 * 16;
-
- var useRequestAborted = !cancellationToken.CanBeCanceled;
- var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken;
-
- var fileStream = new FileStream(
- filePath,
- FileMode.Open,
- FileAccess.Read,
- FileShare.ReadWrite,
- bufferSize: BufferSize,
- options: FileOptions.Asynchronous | FileOptions.SequentialScan);
- await using (fileStream.ConfigureAwait(false))
- {
- try
- {
- localCancel.ThrowIfCancellationRequested();
- fileStream.Seek(offset, SeekOrigin.Begin);
- await StreamCopyOperation
- .CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel)
- .ConfigureAwait(true);
- }
- catch (OperationCanceledException) when (useRequestAborted)
- {
- }
- }
- }
-
- private static bool IsSymLink(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
- }
-}
diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj
index 452b03efb..14ab114fb 100644
--- a/Jellyfin.Server/Jellyfin.Server.csproj
+++ b/Jellyfin.Server/Jellyfin.Server.csproj
@@ -48,10 +48,12 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" />
+ <PackageReference Include="Morestachio" />
<PackageReference Include="prometheus-net" />
<PackageReference Include="prometheus-net.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Thread" />
+ <PackageReference Include="Serilog.Expressions" />
<PackageReference Include="Serilog.Settings.Configuration" />
<PackageReference Include="Serilog.Sinks.Async" />
<PackageReference Include="Serilog.Sinks.Console" />
@@ -76,7 +78,10 @@
<None Update="wwwroot\api-docs\swagger\custom.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
- <None Update="wwwroot\api-docs\banner-dark.svg">
+ <None Update="wwwroot\api-docs\jellyfin.svg">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Update="ServerSetupApp/index.mstemplate.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
diff --git a/Jellyfin.Server/Migrations/IAsyncMigrationRoutine.cs b/Jellyfin.Server/Migrations/IAsyncMigrationRoutine.cs
new file mode 100644
index 000000000..5b6a5fe94
--- /dev/null
+++ b/Jellyfin.Server/Migrations/IAsyncMigrationRoutine.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Interface that describes a migration routine.
+/// </summary>
+internal interface IAsyncMigrationRoutine
+{
+ /// <summary>
+ /// Execute the migration routine.
+ /// </summary>
+ /// <param name="cancellationToken">A cancellation token triggered if the migration should be aborted.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ public Task PerformAsync(CancellationToken cancellationToken);
+}
+
+/// <summary>
+/// Interface that describes a migration routine.
+/// </summary>
+[Obsolete("Use IAsyncMigrationRoutine instead")]
+internal interface IMigrationRoutine
+{
+ /// <summary>
+ /// Execute the migration routine.
+ /// </summary>
+ [Obsolete("Use IAsyncMigrationRoutine.PerformAsync instead")]
+ public void Perform();
+}
diff --git a/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs b/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs
index 78ff1e3fd..d2d80a81e 100644
--- a/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs
+++ b/Jellyfin.Server/Migrations/IDatabaseMigrationRoutine.cs
@@ -7,6 +7,8 @@ namespace Jellyfin.Server.Migrations;
/// <summary>
/// Defines a migration that operates on the Database.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
internal interface IDatabaseMigrationRoutine : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
}
diff --git a/Jellyfin.Server/Migrations/IMigrationRoutine.cs b/Jellyfin.Server/Migrations/IMigrationRoutine.cs
deleted file mode 100644
index 29f681df5..000000000
--- a/Jellyfin.Server/Migrations/IMigrationRoutine.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using System;
-using Jellyfin.Server.Implementations;
-using Microsoft.EntityFrameworkCore.Internal;
-
-namespace Jellyfin.Server.Migrations
-{
- /// <summary>
- /// Interface that describes a migration routine.
- /// </summary>
- internal interface IMigrationRoutine
- {
- /// <summary>
- /// Gets the unique id for this migration. This should never be modified after the migration has been created.
- /// </summary>
- public Guid Id { get; }
-
- /// <summary>
- /// Gets the display name of the migration.
- /// </summary>
- public string Name { get; }
-
- /// <summary>
- /// Gets a value indicating whether to perform migration on a new install.
- /// </summary>
- public bool PerformOnNewInstall { get; }
-
- /// <summary>
- /// Execute the migration routine.
- /// </summary>
- public void Perform();
- }
-}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs b/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs
new file mode 100644
index 000000000..70e54125b
--- /dev/null
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationAttribute.cs
@@ -0,0 +1,68 @@
+#pragma warning disable CA1019 // Define accessors for attribute arguments
+
+using System;
+using System.Globalization;
+using Jellyfin.Server.Migrations.Stages;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Declares an class as an migration with its set metadata.
+/// </summary>
+[AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)]
+public sealed class JellyfinMigrationAttribute : Attribute
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinMigrationAttribute"/> class.
+ /// </summary>
+ /// <param name="order">The ordering this migration should be applied to. Must be a valid DateTime ISO8601 formatted string.</param>
+ /// <param name="name">The name of this Migration.</param>
+#pragma warning disable CS0618 // Type or member is obsolete
+ public JellyfinMigrationAttribute(string order, string name) : this(order, name, null)
+#pragma warning restore CS0618 // Type or member is obsolete
+ {
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinMigrationAttribute"/> class for legacy migrations.
+ /// </summary>
+ /// <param name="order">The ordering this migration should be applied to. Must be a valid DateTime ISO8601 formatted string.</param>
+ /// <param name="name">The name of this Migration.</param>
+ /// <param name="key">[ONLY FOR LEGACY MIGRATIONS]The unique key of this migration. Must be a valid Guid formatted string.</param>
+ [Obsolete("This Constructor should only be used for Legacy migrations. Use the (Order,Name) one for all new ones instead.")]
+ public JellyfinMigrationAttribute(string order, string name, string? key)
+ {
+ Order = DateTime.Parse(order, CultureInfo.InvariantCulture);
+ Name = name;
+ Stage = JellyfinMigrationStageTypes.AppInitialisation;
+ if (key is not null)
+ {
+ Key = Guid.Parse(key);
+ }
+ }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether the annoated migration should be executed on a fresh install.
+ /// </summary>
+ public bool RunMigrationOnSetup { get; set; }
+
+ /// <summary>
+ /// Gets or Sets the stage the annoated migration should be executed at. Defaults to <see cref="JellyfinMigrationStageTypes.CoreInitialisation"/>.
+ /// </summary>
+ public JellyfinMigrationStageTypes Stage { get; set; } = JellyfinMigrationStageTypes.CoreInitialisation;
+
+ /// <summary>
+ /// Gets the ordering of the migration.
+ /// </summary>
+ public DateTime Order { get; }
+
+ /// <summary>
+ /// Gets the name of the migration.
+ /// </summary>
+ public string Name { get; }
+
+ /// <summary>
+ /// Gets the Legacy Key of the migration. Not required for new Migrations.
+ /// </summary>
+ public Guid? Key { get; }
+}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs b/Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs
new file mode 100644
index 000000000..6c8da7e82
--- /dev/null
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationBackupAttribute.cs
@@ -0,0 +1,35 @@
+using System;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Marks an <see cref="JellyfinMigrationAttribute"/> migration and instructs the <see cref="JellyfinMigrationService"/> to perform a backup.
+/// </summary>
+[AttributeUsage(System.AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
+public sealed class JellyfinMigrationBackupAttribute : System.Attribute
+{
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the old library.db should be performed.
+ /// </summary>
+ public bool LegacyLibraryDb { get; set; }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the Database should be performed.
+ /// </summary>
+ public bool JellyfinDb { get; set; }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the metadata folder should be performed.
+ /// </summary>
+ public bool Metadata { get; set; }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the Trickplay folder should be performed.
+ /// </summary>
+ public bool Trickplay { get; set; }
+
+ /// <summary>
+ /// Gets or Sets a value indicating whether a backup of the Subtitles folder should be performed.
+ /// </summary>
+ public bool Subtitles { get; set; }
+}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
new file mode 100644
index 000000000..188d3c4a9
--- /dev/null
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -0,0 +1,459 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Serialization;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.Implementations.SystemBackupService;
+using Jellyfin.Server.Migrations.Stages;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.SystemBackupService;
+using MediaBrowser.Model.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations;
+
+/// <summary>
+/// Handles Migration of the Jellyfin data structure.
+/// </summary>
+internal class JellyfinMigrationService
+{
+ private const string DbFilename = "library.db";
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+ private readonly ILoggerFactory _loggerFactory;
+ private readonly IStartupLogger _startupLogger;
+ private readonly IBackupService? _backupService;
+ private readonly IJellyfinDatabaseProvider? _jellyfinDatabaseProvider;
+ private readonly IApplicationPaths _applicationPaths;
+ private (string? LibraryDb, string? JellyfinDb, BackupManifestDto? FullBackup) _backupKey;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JellyfinMigrationService"/> class.
+ /// </summary>
+ /// <param name="dbContextFactory">Provides access to the jellyfin database.</param>
+ /// <param name="loggerFactory">The logger factory.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
+ /// <param name="applicationPaths">Application paths for library.db backup.</param>
+ /// <param name="backupService">The jellyfin backup service.</param>
+ /// <param name="jellyfinDatabaseProvider">The jellyfin database provider.</param>
+ public JellyfinMigrationService(
+ IDbContextFactory<JellyfinDbContext> dbContextFactory,
+ ILoggerFactory loggerFactory,
+ IStartupLogger<JellyfinMigrationService> startupLogger,
+ IApplicationPaths applicationPaths,
+ IBackupService? backupService = null,
+ IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null)
+ {
+ _dbContextFactory = dbContextFactory;
+ _loggerFactory = loggerFactory;
+ _startupLogger = startupLogger;
+ _backupService = backupService;
+ _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
+ _applicationPaths = applicationPaths;
+#pragma warning disable CS0618 // Type or member is obsolete
+ Migrations = [.. typeof(IMigrationRoutine).Assembly.GetTypes().Where(e => typeof(IMigrationRoutine).IsAssignableFrom(e) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(e))
+ .Select(e => (Type: e, Metadata: e.GetCustomAttribute<JellyfinMigrationAttribute>(), Backup: e.GetCustomAttributes<JellyfinMigrationBackupAttribute>()))
+ .Where(e => e.Metadata is not null)
+ .GroupBy(e => e.Metadata!.Stage)
+ .Select(f =>
+ {
+ var stage = new MigrationStage(f.Key);
+ foreach (var item in f)
+ {
+ JellyfinMigrationBackupAttribute? backupMetadata = null;
+ if (item.Backup?.Any() == true)
+ {
+ backupMetadata = item.Backup.Aggregate(MergeBackupAttributes);
+ }
+
+ stage.Add(new(item.Type, item.Metadata!, backupMetadata));
+ }
+
+ return stage;
+ })];
+#pragma warning restore CS0618 // Type or member is obsolete
+ }
+
+ private interface IInternalMigration
+ {
+ Task PerformAsync(IStartupLogger logger);
+ }
+
+ private HashSet<MigrationStage> Migrations { get; set; }
+
+ public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
+ {
+ var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
+ logger.LogInformation("Initialise Migration service.");
+ var xmlSerializer = new MyXmlSerializer();
+ var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
+ ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)!
+ : new ServerConfiguration();
+ if (!serverConfig.IsStartupWizardCompleted)
+ {
+ logger.LogInformation("System initialisation detected. Seed data.");
+ var flatApplyMigrations = Migrations.SelectMany(e => e.Where(f => !f.Metadata.RunMigrationOnSetup)).ToArray();
+
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var databaseCreator = dbContext.Database.GetService<IDatabaseCreator>() as IRelationalDatabaseCreator
+ ?? throw new InvalidOperationException("Jellyfin does only support relational databases.");
+ if (!await databaseCreator.ExistsAsync().ConfigureAwait(false))
+ {
+ await databaseCreator.CreateAsync().ConfigureAwait(false);
+ }
+
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+
+ await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
+ var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
+ var startupScripts = flatApplyMigrations
+ .Where(e => !appliedMigrations.Any(f => f != e.BuildCodeMigrationId()))
+ .Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))))
+ .ToArray();
+ foreach (var item in startupScripts)
+ {
+ logger.LogInformation("Seed migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
+ await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
+ }
+ }
+
+ logger.LogInformation("Migration system initialisation completed.");
+ }
+ else
+ {
+ // migrate any existing migration.xml files
+ var migrationConfigPath = Path.Join(appPaths.ConfigurationDirectoryPath, "migrations.xml");
+ var migrationOptions = File.Exists(migrationConfigPath)
+ ? (MigrationOptions)xmlSerializer.DeserializeFromFile(typeof(MigrationOptions), migrationConfigPath)!
+ : null;
+ if (migrationOptions is not null && migrationOptions.Applied.Count > 0)
+ {
+ logger.LogInformation("Old migration style migration.xml detected. Migrate now.");
+ try
+ {
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
+ var lastOldAppliedMigration = Migrations
+ .SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations.
+ .Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value)))
+ .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
+ .OrderBy(e => e.BuildCodeMigrationId())
+ .Last(); // this is the latest migration applied in the old migration.xml
+
+ IReadOnlyList<CodeMigration> oldMigrations = [
+ .. Migrations
+ .SelectMany(e => e)
+ .OrderBy(e => e.BuildCodeMigrationId())
+ .TakeWhile(e => e.BuildCodeMigrationId() != lastOldAppliedMigration.BuildCodeMigrationId()),
+ lastOldAppliedMigration
+ ];
+ // those are all migrations that had to run in the old migration system, even if not noted in the migration.xml file.
+
+ var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))));
+ foreach (var item in startupScripts)
+ {
+ logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
+ await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
+ }
+
+ logger.LogInformation("Rename old migration.xml to migration.xml.backup");
+ File.Move(migrationConfigPath, Path.ChangeExtension(migrationConfigPath, ".xml.backup"), true);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogCritical(ex, "Failed to apply migrations");
+ throw;
+ }
+ }
+ }
+ }
+
+ public async Task 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>) ?? [];
+
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
+ var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
+ var pendingCodeMigrations = migrationStage
+ .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
+ .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
+ .ToArray();
+
+ (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
+ if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
+ {
+ pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
+ .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
+ .ToArray();
+ }
+
+ (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
+ logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
+ var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
+
+ foreach (var item in migrations)
+ {
+ var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
+ try
+ {
+ migrationLogger.LogInformation("Perform migration {Name}", item.Key);
+ await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
+ migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
+ }
+ catch (Exception ex)
+ {
+ migrationLogger.LogCritical("Error: {Error}", ex.Message);
+ migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
+
+ if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
+ {
+ if (_backupKey.LibraryDb is not null)
+ {
+ migrationLogger.LogInformation("Attempt to rollback librarydb.");
+ try
+ {
+ var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
+ File.Move(_backupKey.LibraryDb, libraryDbPath, true);
+ }
+ catch (Exception inner)
+ {
+ migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
+ }
+ }
+
+ if (_backupKey.JellyfinDb is not null)
+ {
+ migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
+ try
+ {
+ await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception inner)
+ {
+ migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
+ }
+ }
+
+ if (_backupKey.FullBackup is not null)
+ {
+ migrationLogger.LogInformation("Attempt to rollback from backup.");
+ try
+ {
+ await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
+ }
+ catch (Exception inner)
+ {
+ migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
+ }
+ }
+ }
+
+ throw;
+ }
+ }
+ }
+ }
+
+ private static string GetJellyfinVersion()
+ {
+ return Assembly.GetEntryAssembly()!.GetName().Version!.ToString();
+ }
+
+ public async Task CleanupSystemAfterMigration(ILogger logger)
+ {
+ if (_backupKey != default)
+ {
+ if (_backupKey.LibraryDb is not null)
+ {
+ logger.LogInformation("Attempt to cleanup librarydb backup.");
+ try
+ {
+ File.Delete(_backupKey.LibraryDb);
+ }
+ catch (Exception inner)
+ {
+ logger.LogCritical(inner, "Could not cleanup {LibraryPath}.", _backupKey.LibraryDb);
+ }
+ }
+
+ if (_backupKey.JellyfinDb is not null && _jellyfinDatabaseProvider is not null)
+ {
+ logger.LogInformation("Attempt to cleanup JellyfinDb backup.");
+ try
+ {
+ await _jellyfinDatabaseProvider.DeleteBackup(_backupKey.JellyfinDb).ConfigureAwait(false);
+ }
+ catch (Exception inner)
+ {
+ logger.LogCritical(inner, "Could not cleanup {LibraryPath}.", _backupKey.JellyfinDb);
+ }
+ }
+
+ if (_backupKey.FullBackup is not null)
+ {
+ logger.LogInformation("Attempt to cleanup from migration backup.");
+ try
+ {
+ File.Delete(_backupKey.FullBackup.Path);
+ }
+ catch (Exception inner)
+ {
+ logger.LogCritical(inner, "Could not cleanup backup {Backup}.", _backupKey.FullBackup.Path);
+ }
+ }
+ }
+ }
+
+ public async Task PrepareSystemForMigration(ILogger logger)
+ {
+ logger.LogInformation("Prepare system for possible migrations");
+ JellyfinMigrationBackupAttribute backupInstruction;
+ IReadOnlyList<HistoryRow> appliedMigrations;
+ var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ var historyRepository = dbContext.GetService<IHistoryRepository>();
+ var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
+ appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
+ backupInstruction = new JellyfinMigrationBackupAttribute()
+ {
+ JellyfinDb = migrationsAssembly.Migrations.Any(f => appliedMigrations.All(e => e.MigrationId != f.Key))
+ };
+ }
+
+ backupInstruction = Migrations.SelectMany(e => e)
+ .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
+ .Select(e => e.BackupRequirements)
+ .Where(e => e is not null)
+ .Aggregate(backupInstruction, MergeBackupAttributes!);
+
+ if (backupInstruction.LegacyLibraryDb)
+ {
+ logger.LogInformation("A migration will attempt to modify the library.db, will attempt to backup the file now.");
+ // for legacy migrations that still operates on the library.db
+ var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
+ if (File.Exists(libraryDbPath))
+ {
+ for (int i = 1; ; i++)
+ {
+ var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", libraryDbPath, i);
+ if (!File.Exists(bakPath))
+ {
+ try
+ {
+ logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath);
+ File.Copy(libraryDbPath, bakPath);
+ _backupKey = (bakPath, _backupKey.JellyfinDb, _backupKey.FullBackup);
+ logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath);
+ break;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
+ throw;
+ }
+ }
+ }
+
+ logger.LogInformation("{Library} has been backed up as {BackupPath}", DbFilename, _backupKey.LibraryDb);
+ }
+ else
+ {
+ logger.LogError("Cannot make a backup of {Library} at path {BackupPath} because file could not be found at {LibraryPath}", DbFilename, libraryDbPath, _applicationPaths.DataPath);
+ }
+ }
+
+ if (backupInstruction.JellyfinDb && _jellyfinDatabaseProvider is not null)
+ {
+ logger.LogInformation("A migration will attempt to modify the jellyfin.db, will attempt to backup the file now.");
+ _backupKey = (_backupKey.LibraryDb, await _jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false), _backupKey.FullBackup);
+ logger.LogInformation("Jellyfin database has been backed up as {BackupPath}", _backupKey.JellyfinDb);
+ }
+
+ if (_backupService is not null && (backupInstruction.Metadata || backupInstruction.Subtitles || backupInstruction.Trickplay))
+ {
+ logger.LogInformation("A migration will attempt to modify system resources. Will attempt to create backup now.");
+ _backupKey = (_backupKey.LibraryDb, _backupKey.JellyfinDb, await _backupService.CreateBackupAsync(new BackupOptionsDto()
+ {
+ Metadata = backupInstruction.Metadata,
+ Subtitles = backupInstruction.Subtitles,
+ Trickplay = backupInstruction.Trickplay,
+ Database = false // database backups are explicitly handled by the provider itself as the backup service requires parity with the current model
+ }).ConfigureAwait(false));
+ logger.LogInformation("Pre-Migration backup successfully created as {BackupKey}", _backupKey.FullBackup.Path);
+ }
+ }
+
+ private static JellyfinMigrationBackupAttribute MergeBackupAttributes(JellyfinMigrationBackupAttribute left, JellyfinMigrationBackupAttribute right)
+ {
+ return new JellyfinMigrationBackupAttribute()
+ {
+ JellyfinDb = left!.JellyfinDb || right!.JellyfinDb,
+ LegacyLibraryDb = left.LegacyLibraryDb || right!.LegacyLibraryDb,
+ Metadata = left.Metadata || right!.Metadata,
+ Subtitles = left.Subtitles || right!.Subtitles,
+ Trickplay = left.Trickplay || right!.Trickplay
+ };
+ }
+
+ private class InternalCodeMigration : IInternalMigration
+ {
+ private readonly CodeMigration _codeMigration;
+ private readonly IServiceProvider? _serviceProvider;
+ private JellyfinDbContext _dbContext;
+
+ public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider? serviceProvider, JellyfinDbContext dbContext)
+ {
+ _codeMigration = codeMigration;
+ _serviceProvider = serviceProvider;
+ _dbContext = dbContext;
+ }
+
+ public async Task PerformAsync(IStartupLogger logger)
+ {
+ await _codeMigration.Perform(_serviceProvider, logger, CancellationToken.None).ConfigureAwait(false);
+
+ var historyRepository = _dbContext.GetService<IHistoryRepository>();
+ var createScript = historyRepository.GetInsertScript(new HistoryRow(_codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()));
+ await _dbContext.Database.ExecuteSqlRawAsync(createScript).ConfigureAwait(false);
+ }
+ }
+
+ private class InternalDatabaseMigration : IInternalMigration
+ {
+ private readonly JellyfinDbContext _jellyfinDbContext;
+ private KeyValuePair<string, TypeInfo> _databaseMigrationInfo;
+
+ public InternalDatabaseMigration(KeyValuePair<string, TypeInfo> databaseMigrationInfo, JellyfinDbContext jellyfinDbContext)
+ {
+ _databaseMigrationInfo = databaseMigrationInfo;
+ _jellyfinDbContext = jellyfinDbContext;
+ }
+
+ public async Task PerformAsync(IStartupLogger logger)
+ {
+ var migrator = _jellyfinDbContext.GetService<IMigrator>();
+ await migrator.MigrateAsync(_databaseMigrationInfo.Key).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs
deleted file mode 100644
index c223576da..000000000
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ /dev/null
@@ -1,204 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using Emby.Server.Implementations;
-using Emby.Server.Implementations.Serialization;
-using Jellyfin.Database.Implementations;
-using Jellyfin.Server.Implementations;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Model.Configuration;
-using Microsoft.EntityFrameworkCore.Storage;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-
-namespace Jellyfin.Server.Migrations
-{
- /// <summary>
- /// The class that knows which migrations to apply and how to apply them.
- /// </summary>
- public sealed class MigrationRunner
- {
- /// <summary>
- /// The list of known pre-startup migrations, in order of applicability.
- /// </summary>
- private static readonly Type[] _preStartupMigrationTypes =
- {
- typeof(PreStartupRoutines.CreateNetworkConfiguration),
- typeof(PreStartupRoutines.MigrateMusicBrainzTimeout),
- typeof(PreStartupRoutines.MigrateNetworkConfiguration),
- typeof(PreStartupRoutines.MigrateEncodingOptions),
- typeof(PreStartupRoutines.RenameEnableGroupingIntoCollections)
- };
-
- /// <summary>
- /// The list of known migrations, in order of applicability.
- /// </summary>
- private static readonly Type[] _migrationTypes =
- {
- typeof(Routines.DisableTranscodingThrottling),
- typeof(Routines.CreateUserLoggingConfigFile),
- typeof(Routines.MigrateActivityLogDb),
- typeof(Routines.RemoveDuplicateExtras),
- typeof(Routines.AddDefaultPluginRepository),
- typeof(Routines.MigrateUserDb),
- typeof(Routines.ReaddDefaultPluginRepository),
- typeof(Routines.MigrateDisplayPreferencesDb),
- typeof(Routines.RemoveDownloadImagesInAdvance),
- typeof(Routines.MigrateAuthenticationDb),
- typeof(Routines.FixPlaylistOwner),
- typeof(Routines.AddDefaultCastReceivers),
- typeof(Routines.UpdateDefaultPluginRepository),
- typeof(Routines.FixAudioData),
- typeof(Routines.RemoveDuplicatePlaylistChildren),
- typeof(Routines.MigrateLibraryDb),
- typeof(Routines.MoveExtractedFiles),
- typeof(Routines.MigrateRatingLevels),
- typeof(Routines.MoveTrickplayFiles),
- typeof(Routines.MigrateKeyframeData),
- };
-
- /// <summary>
- /// Run all needed migrations.
- /// </summary>
- /// <param name="host">CoreAppHost that hosts current version.</param>
- /// <param name="loggerFactory">Factory for making the logger.</param>
- /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
- public static async Task Run(CoreAppHost host, ILoggerFactory loggerFactory)
- {
- var logger = loggerFactory.CreateLogger<MigrationRunner>();
- var migrations = _migrationTypes
- .Select(m => ActivatorUtilities.CreateInstance(host.ServiceProvider, m))
- .OfType<IMigrationRoutine>()
- .ToArray();
-
- var migrationOptions = host.ConfigurationManager.GetConfiguration<MigrationOptions>(MigrationsListStore.StoreKey);
- HandleStartupWizardCondition(migrations, migrationOptions, host.ConfigurationManager.Configuration.IsStartupWizardCompleted, logger);
- await PerformMigrations(migrations, migrationOptions, options => host.ConfigurationManager.SaveConfiguration(MigrationsListStore.StoreKey, options), logger, host.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>())
- .ConfigureAwait(false);
- }
-
- /// <summary>
- /// Run all needed pre-startup migrations.
- /// </summary>
- /// <param name="appPaths">Application paths.</param>
- /// <param name="loggerFactory">Factory for making the logger.</param>
- /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
- public static async Task RunPreStartup(ServerApplicationPaths appPaths, ILoggerFactory loggerFactory)
- {
- var logger = loggerFactory.CreateLogger<MigrationRunner>();
- var migrations = _preStartupMigrationTypes
- .Select(m => Activator.CreateInstance(m, appPaths, loggerFactory))
- .OfType<IMigrationRoutine>()
- .ToArray();
-
- var xmlSerializer = new MyXmlSerializer();
- var migrationConfigPath = Path.Join(appPaths.ConfigurationDirectoryPath, MigrationsListStore.StoreKey.ToLowerInvariant() + ".xml");
- var migrationOptions = File.Exists(migrationConfigPath)
- ? (MigrationOptions)xmlSerializer.DeserializeFromFile(typeof(MigrationOptions), migrationConfigPath)!
- : new MigrationOptions();
-
- // We have to deserialize it manually since the configuration manager may overwrite it
- var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
- ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)!
- : new ServerConfiguration();
-
- HandleStartupWizardCondition(migrations, migrationOptions, serverConfig.IsStartupWizardCompleted, logger);
- await PerformMigrations(migrations, migrationOptions, options => xmlSerializer.SerializeToFile(options, migrationConfigPath), logger, null).ConfigureAwait(false);
- }
-
- private static void HandleStartupWizardCondition(IEnumerable<IMigrationRoutine> migrations, MigrationOptions migrationOptions, bool isStartWizardCompleted, ILogger logger)
- {
- if (isStartWizardCompleted)
- {
- return;
- }
-
- // If startup wizard is not finished, this is a fresh install.
- var onlyOldInstalls = migrations.Where(m => !m.PerformOnNewInstall).ToArray();
- logger.LogInformation("Marking following migrations as applied because this is a fresh install: {@OnlyOldInstalls}", onlyOldInstalls.Select(m => m.Name));
- migrationOptions.Applied.AddRange(onlyOldInstalls.Select(m => (m.Id, m.Name)));
- }
-
- private static async Task PerformMigrations(
- IMigrationRoutine[] migrations,
- MigrationOptions migrationOptions,
- Action<MigrationOptions> saveConfiguration,
- ILogger logger,
- IJellyfinDatabaseProvider? jellyfinDatabaseProvider)
- {
- // save already applied migrations, and skip them thereafter
- saveConfiguration(migrationOptions);
- var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet();
- var migrationsToBeApplied = migrations.Where(e => !appliedMigrationIds.Contains(e.Id)).ToArray();
-
- string? migrationKey = null;
- if (jellyfinDatabaseProvider is not null && migrationsToBeApplied.Any(f => f is IDatabaseMigrationRoutine))
- {
- logger.LogInformation("Performing database backup");
- try
- {
- migrationKey = await jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false);
- logger.LogInformation("Database backup with key '{BackupKey}' has been successfully created.", migrationKey);
- }
- catch (NotImplementedException)
- {
- logger.LogWarning("Could not perform backup of database before migration because provider does not support it");
- }
- }
-
- List<IMigrationRoutine> databaseMigrations = [];
- try
- {
- foreach (var migrationRoutine in migrationsToBeApplied)
- {
- logger.LogInformation("Applying migration '{Name}'", migrationRoutine.Name);
- var isDbMigration = migrationRoutine is IDatabaseMigrationRoutine;
-
- if (isDbMigration)
- {
- databaseMigrations.Add(migrationRoutine);
- }
-
- try
- {
- migrationRoutine.Perform();
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "Could not apply migration '{Name}'", migrationRoutine.Name);
- throw;
- }
-
- // Mark the migration as completed
- logger.LogInformation("Migration '{Name}' applied successfully", migrationRoutine.Name);
- if (!isDbMigration)
- {
- migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name));
- saveConfiguration(migrationOptions);
- logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name);
- }
- }
- }
- catch (Exception) when (migrationKey is not null && jellyfinDatabaseProvider is not null)
- {
- if (databaseMigrations.Count != 0)
- {
- logger.LogInformation("Rolling back database as migrations reported failure.");
- await jellyfinDatabaseProvider.RestoreBackupFast(migrationKey, CancellationToken.None).ConfigureAwait(false);
- }
-
- throw;
- }
-
- foreach (var migrationRoutine in databaseMigrations)
- {
- migrationOptions.Applied.Add((migrationRoutine.Id, migrationRoutine.Name));
- saveConfiguration(migrationOptions);
- logger.LogDebug("Migration '{Name}' marked as applied in configuration.", migrationRoutine.Name);
- }
- }
- }
-}
diff --git a/Jellyfin.Server/Migrations/MigrationsFactory.cs b/Jellyfin.Server/Migrations/MigrationsFactory.cs
deleted file mode 100644
index 23c1b1ee6..000000000
--- a/Jellyfin.Server/Migrations/MigrationsFactory.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System.Collections.Generic;
-using MediaBrowser.Common.Configuration;
-
-namespace Jellyfin.Server.Migrations
-{
- /// <summary>
- /// A factory that can find a persistent file of the migration configuration, which lists all applied migrations.
- /// </summary>
- public class MigrationsFactory : IConfigurationFactory
- {
- /// <inheritdoc/>
- public IEnumerable<ConfigurationStore> GetConfigurations()
- {
- return new[]
- {
- new MigrationsListStore()
- };
- }
- }
-}
diff --git a/Jellyfin.Server/Migrations/MigrationsListStore.cs b/Jellyfin.Server/Migrations/MigrationsListStore.cs
deleted file mode 100644
index 7a1ca6671..000000000
--- a/Jellyfin.Server/Migrations/MigrationsListStore.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using MediaBrowser.Common.Configuration;
-
-namespace Jellyfin.Server.Migrations
-{
- /// <summary>
- /// A configuration that lists all the migration routines that were applied.
- /// </summary>
- public class MigrationsListStore : ConfigurationStore
- {
- /// <summary>
- /// The name of the configuration in the storage.
- /// </summary>
- public static readonly string StoreKey = "migrations";
-
- /// <summary>
- /// Initializes a new instance of the <see cref="MigrationsListStore"/> class.
- /// </summary>
- public MigrationsListStore()
- {
- ConfigurationType = typeof(MigrationOptions);
- Key = StoreKey;
- }
- }
-}
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs
index 8462d0a8c..fd472cff7 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs
@@ -8,7 +8,10 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.PreStartupRoutines;
/// <inheritdoc />
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T00:00:00", nameof(CreateNetworkConfiguration), "9B354818-94D5-4B68-AC49-E35CB85F9D84", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
public class CreateNetworkConfiguration : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ServerApplicationPaths _applicationPaths;
private readonly ILogger<CreateNetworkConfiguration> _logger;
@@ -25,15 +28,6 @@ public class CreateNetworkConfiguration : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("9B354818-94D5-4B68-AC49-E35CB85F9D84");
-
- /// <inheritdoc />
- public string Name => nameof(CreateNetworkConfiguration);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "network.xml");
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
index 61f5620dc..0141b43c9 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateEncodingOptions.cs
@@ -10,7 +10,10 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.PreStartupRoutines;
/// <inheritdoc />
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T03:00:00", nameof(MigrateEncodingOptions), "A8E61960-7726-4450-8F3D-82C12DAABBCB", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
public class MigrateEncodingOptions : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ServerApplicationPaths _applicationPaths;
private readonly ILogger<MigrateEncodingOptions> _logger;
@@ -27,15 +30,6 @@ public class MigrateEncodingOptions : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("A8E61960-7726-4450-8F3D-82C12DAABBCB");
-
- /// <inheritdoc />
- public string Name => nameof(MigrateEncodingOptions);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "encoding.xml");
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs
index 580282a5f..e8da9f515 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs
@@ -9,7 +9,10 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.PreStartupRoutines;
/// <inheritdoc />
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T02:00:00", nameof(MigrateMusicBrainzTimeout), "A6DCACF4-C057-4Ef9-80D3-61CEF9DDB4F0", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
public class MigrateMusicBrainzTimeout : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ServerApplicationPaths _applicationPaths;
private readonly ILogger<MigrateMusicBrainzTimeout> _logger;
@@ -26,15 +29,6 @@ public class MigrateMusicBrainzTimeout : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("A6DCACF4-C057-4Ef9-80D3-61CEF9DDB4F0");
-
- /// <inheritdoc />
- public string Name => nameof(MigrateMusicBrainzTimeout);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.PluginConfigurationsPath, "Jellyfin.Plugin.MusicBrainz.xml");
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs
index 09b292171..f2790c1a1 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs
@@ -11,6 +11,7 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.PreStartupRoutines;
/// <inheritdoc />
+[JellyfinMigration("2025-04-20T01:00:00", nameof(MigrateNetworkConfiguration), "4FB5C950-1991-11EE-9B4B-0800200C9A66", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
public class MigrateNetworkConfiguration : IMigrationRoutine
{
private readonly ServerApplicationPaths _applicationPaths;
@@ -28,15 +29,6 @@ public class MigrateNetworkConfiguration : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("4FB5C950-1991-11EE-9B4B-0800200C9A66");
-
- /// <inheritdoc />
- public string Name => nameof(MigrateNetworkConfiguration);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "network.xml");
diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs
index 0a37b35a6..995b2bbf9 100644
--- a/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs
+++ b/Jellyfin.Server/Migrations/PreStartupRoutines/RenameEnableGroupingIntoCollections.cs
@@ -9,7 +9,10 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.PreStartupRoutines;
/// <inheritdoc />
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T04:00:00", nameof(RenameEnableGroupingIntoCollections), "E73B777D-CD5C-4E71-957A-B86B3660B7CF", Stage = Stages.JellyfinMigrationStageTypes.PreInitialisation)]
public class RenameEnableGroupingIntoCollections : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ServerApplicationPaths _applicationPaths;
private readonly ILogger<RenameEnableGroupingIntoCollections> _logger;
@@ -26,15 +29,6 @@ public class RenameEnableGroupingIntoCollections : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("E73B777D-CD5C-4E71-957A-B86B3660B7CF");
-
- /// <inheritdoc />
- public string Name => nameof(RenameEnableGroupingIntoCollections);
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "system.xml");
diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
index 2047ec743..00d152b4b 100644
--- a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
+++ b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
@@ -7,7 +7,10 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to add the default cast receivers to the system config.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T16:00:00", nameof(AddDefaultCastReceivers), "34A1A1C4-5572-418E-A2F8-32CDFE2668E8", RunMigrationOnSetup = true)]
public class AddDefaultCastReceivers : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly IServerConfigurationManager _serverConfigurationManager;
@@ -21,15 +24,6 @@ public class AddDefaultCastReceivers : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => new("34A1A1C4-5572-418E-A2F8-32CDFE2668E8");
-
- /// <inheritdoc />
- public string Name => "AddDefaultCastReceivers";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => true;
-
- /// <inheritdoc />
public void Perform()
{
_serverConfigurationManager.Configuration.CastReceiverApplications =
diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
index fc6b5d597..8c8398a16 100644
--- a/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/AddDefaultPluginRepository.cs
@@ -7,7 +7,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// Migration to initialize system configuration with the default plugin repository.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T09:00:00", nameof(AddDefaultPluginRepository), "EB58EBEE-9514-4B9B-8225-12E1A40020DF", RunMigrationOnSetup = true)]
public class AddDefaultPluginRepository : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly IServerConfigurationManager _serverConfigurationManager;
@@ -27,15 +30,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("EB58EBEE-9514-4B9B-8225-12E1A40020DF");
-
- /// <inheritdoc/>
- public string Name => "AddDefaultPluginRepository";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => true;
-
- /// <inheritdoc/>
public void Perform()
{
_serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo };
diff --git a/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs b/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs
new file mode 100644
index 000000000..d5c5f3d92
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/CleanMusicArtist.cs
@@ -0,0 +1,47 @@
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Cleans up all Music artists that have been migrated in the 10.11 RC migrations.
+/// </summary>
+[JellyfinMigration("2025-10-09T20:00:00", nameof(CleanMusicArtist))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+public class CleanMusicArtist : IAsyncMigrationRoutine
+{
+ private readonly IStartupLogger<CleanMusicArtist> _startupLogger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CleanMusicArtist"/> class.
+ /// </summary>
+ /// <param name="startupLogger">The startup logger.</param>
+ /// <param name="dbContextFactory">The Db context factory.</param>
+ public CleanMusicArtist(IStartupLogger<CleanMusicArtist> startupLogger, IDbContextFactory<JellyfinDbContext> dbContextFactory)
+ {
+ _startupLogger = startupLogger;
+ _dbContextFactory = dbContextFactory;
+ }
+
+ /// <inheritdoc/>
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ var peoples = context.Peoples.Where(e => e.PersonType == nameof(PersonKind.Artist) || e.PersonType == nameof(PersonKind.AlbumArtist));
+ _startupLogger.LogInformation("Delete {Number} Artist and Album Artist person types from db", await peoples.CountAsync(cancellationToken).ConfigureAwait(false));
+
+ await peoples
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
index 5a8ef2e1c..1326a6dc8 100644
--- a/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
+++ b/Jellyfin.Server/Migrations/Routines/CreateUserLoggingConfigFile.cs
@@ -12,7 +12,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// If the deprecated logging.json file exists and has a custom config, it will be used as logging.user.json,
/// otherwise a blank file will be created.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T06:00:00", nameof(CreateUserLoggingConfigFile), "EF103419-8451-40D8-9F34-D1A8E93A1679")]
internal class CreateUserLoggingConfigFile : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
/// <summary>
/// File history for logging.json as existed during this migration creation. The contents for each has been minified.
@@ -43,15 +46,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{EF103419-8451-40D8-9F34-D1A8E93A1679}");
-
- /// <inheritdoc/>
- public string Name => "CreateLoggingConfigHierarchy";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var logDirectory = _appPaths.ConfigurationDirectoryPath;
diff --git a/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs b/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs
new file mode 100644
index 000000000..6edfcbcfd
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs
@@ -0,0 +1,32 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to disable legacy authorization in the system config.
+/// </summary>
+[JellyfinMigration("2025-11-18T16:00:00", nameof(DisableLegacyAuthorization))]
+public class DisableLegacyAuthorization : IAsyncMigrationRoutine
+{
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ /// <inheritdoc />
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ _serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
+ _serverConfigurationManager.SaveConfiguration();
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs b/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
index 378e88e25..acf2835fe 100644
--- a/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
+++ b/Jellyfin.Server/Migrations/Routines/DisableTranscodingThrottling.cs
@@ -7,7 +7,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// Disable transcode throttling for all installations since it is currently broken for certain video formats.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T05:00:00", nameof(DisableTranscodingThrottling), "4124C2CD-E939-4FFB-9BE9-9B311C413638")]
internal class DisableTranscodingThrottling : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ILogger<DisableTranscodingThrottling> _logger;
private readonly IConfigurationManager _configManager;
@@ -19,15 +22,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{4124C2CD-E939-4FFB-9BE9-9B311C413638}");
-
- /// <inheritdoc/>
- public string Name => "DisableTranscodingThrottling";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
// Set EnableThrottling to false since it wasn't used before and may introduce issues
diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
index a20253369..05ded06ba 100644
--- a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
+++ b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
@@ -16,9 +16,12 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// Fixes the data column of audio types to be deserializable.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T18:00:00", nameof(FixAudioData), "CF6FABC2-9FBE-4933-84A5-FFE52EF22A58")]
+ [JellyfinMigrationBackup(LegacyLibraryDb = true)]
internal class FixAudioData : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
- private const string DbFilename = "library.db";
private readonly ILogger<FixAudioData> _logger;
private readonly IServerApplicationPaths _applicationPaths;
private readonly IItemRepository _itemRepository;
@@ -34,40 +37,8 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{CF6FABC2-9FBE-4933-84A5-FFE52EF22A58}");
-
- /// <inheritdoc/>
- public string Name => "FixAudioData";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
- var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
-
- // Back up the database before modifying any entries
- for (int i = 1; ; i++)
- {
- var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
- if (!File.Exists(bakPath))
- {
- try
- {
- _logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath);
- File.Copy(dbPath, bakPath);
- _logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath);
- break;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
- throw;
- }
- }
- }
-
_logger.LogInformation("Backfilling audio lyrics data to database.");
var startIndex = 0;
var records = _itemRepository.GetCount(new InternalItemsQuery
diff --git a/Jellyfin.Server/Migrations/Routines/FixDates.cs b/Jellyfin.Server/Migrations/Routines/FixDates.cs
new file mode 100644
index 000000000..a5b11b11d
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/FixDates.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Diagnostics;
+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;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to fix dates saved in the database to always be UTC.
+/// </summary>
+[JellyfinMigration("2025-06-20T18:00:00", nameof(FixDates))]
+public class FixDates : IAsyncMigrationRoutine
+{
+ private const int PageSize = 5000;
+
+ private readonly ILogger _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FixDates"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI integration.</param>
+ /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
+ public FixDates(
+ ILogger<FixDates> logger,
+ IStartupLogger<FixDates> startupLogger,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
+ {
+ _logger = startupLogger.With(logger);
+ _dbProvider = dbProvider;
+ }
+
+ /// <inheritdoc />
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ if (!TimeZoneInfo.Local.Equals(TimeZoneInfo.Utc))
+ {
+ var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (context.ConfigureAwait(false))
+ {
+ var sw = Stopwatch.StartNew();
+
+ await FixBaseItemsAsync(context, sw, cancellationToken).ConfigureAwait(false);
+ sw.Reset();
+ await FixChaptersAsync(context, sw, cancellationToken).ConfigureAwait(false);
+ sw.Reset();
+ await FixBaseItemImageInfos(context, sw, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task FixBaseItemsAsync(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken)
+ {
+ int itemCount = 0;
+
+ var baseQuery = context.BaseItems.OrderBy(e => e.Id);
+ var records = baseQuery.Count();
+ _logger.LogInformation("Fixing dates for {Count} BaseItems.", records);
+
+ sw.Start();
+ await foreach (var result in context.BaseItems.OrderBy(e => e.Id)
+ .WithPartitionProgress(
+ (partition) =>
+ _logger.LogInformation(
+ "Processing BaseItems batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}",
+ partition + 1,
+ Math.Min((partition + 1) * PageSize, records),
+ records,
+ sw.Elapsed))
+ .PartitionEagerAsync(PageSize, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ result.DateCreated = ToUniversalTime(result.DateCreated);
+ result.DateLastMediaAdded = ToUniversalTime(result.DateLastMediaAdded);
+ result.DateLastRefreshed = ToUniversalTime(result.DateLastRefreshed);
+ result.DateLastSaved = ToUniversalTime(result.DateLastSaved);
+ result.DateModified = ToUniversalTime(result.DateModified);
+ itemCount++;
+ }
+
+ var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("BaseItems: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed);
+ }
+
+ private async Task FixChaptersAsync(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken)
+ {
+ int itemCount = 0;
+
+ var baseQuery = context.Chapters;
+ var records = baseQuery.Count();
+ _logger.LogInformation("Fixing dates for {Count} Chapters.", records);
+
+ sw.Start();
+ await foreach (var result in context.Chapters.OrderBy(e => e.ItemId)
+ .WithPartitionProgress(
+ (partition) =>
+ _logger.LogInformation(
+ "Processing Chapter batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}",
+ partition + 1,
+ Math.Min((partition + 1) * PageSize, records),
+ records,
+ sw.Elapsed))
+ .PartitionEagerAsync(PageSize, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ result.ImageDateModified = ToUniversalTime(result.ImageDateModified, true);
+ itemCount++;
+ }
+
+ var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("Chapters: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed);
+ }
+
+ private async Task FixBaseItemImageInfos(JellyfinDbContext context, Stopwatch sw, CancellationToken cancellationToken)
+ {
+ int itemCount = 0;
+
+ var baseQuery = context.BaseItemImageInfos;
+ var records = baseQuery.Count();
+ _logger.LogInformation("Fixing dates for {Count} BaseItemImageInfos.", records);
+
+ sw.Start();
+ await foreach (var result in context.BaseItemImageInfos.OrderBy(e => e.Id)
+ .WithPartitionProgress(
+ (partition) =>
+ _logger.LogInformation(
+ "Processing BaseItemImageInfos batch {BatchNumber} ({ProcessedSoFar}/{TotalRecords}) - Time: {ElapsedTime}",
+ partition + 1,
+ Math.Min((partition + 1) * PageSize, records),
+ records,
+ sw.Elapsed))
+ .PartitionEagerAsync(PageSize, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ result.DateModified = ToUniversalTime(result.DateModified);
+ itemCount++;
+ }
+
+ var saveCount = await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ _logger.LogInformation("BaseItemImageInfos: Processed {ItemCount} items, saved {SaveCount} changes in {ElapsedTime}", itemCount, saveCount, sw.Elapsed);
+ }
+
+ private DateTime? ToUniversalTime(DateTime? dateTime, bool isUTC = false)
+ {
+ if (dateTime is null)
+ {
+ return null;
+ }
+
+ if (dateTime.Value.Year == 1 && dateTime.Value.Month == 1 && dateTime.Value.Day == 1)
+ {
+ return null;
+ }
+
+ if (dateTime.Value.Kind == DateTimeKind.Utc || isUTC)
+ {
+ return dateTime.Value;
+ }
+
+ return dateTime.Value.ToUniversalTime();
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
index 192c170b2..56614ece3 100644
--- a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
+++ b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
@@ -13,7 +13,10 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Properly set playlist owner.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T15:00:00", nameof(FixPlaylistOwner), "615DFA9E-2497-4DBB-A472-61938B752C5B")]
internal class FixPlaylistOwner : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ILogger<FixPlaylistOwner> _logger;
private readonly ILibraryManager _libraryManager;
@@ -30,15 +33,6 @@ internal class FixPlaylistOwner : IMigrationRoutine
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{615DFA9E-2497-4DBB-A472-61938B752C5B}");
-
- /// <inheritdoc/>
- public string Name => "FixPlaylistOwner";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
index e9fe9abce..8c8563190 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs
@@ -14,7 +14,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// The migration routine for migrating the activity log database to EF Core.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T07:00:00", nameof(MigrateActivityLogDb), "3793eb59-bc8c-456c-8b9f-bd5a62a42978")]
public class MigrateActivityLogDb : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private const string DbFilename = "activitylog.db";
@@ -36,15 +39,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("3793eb59-bc8c-456c-8b9f-bd5a62a42978");
-
- /// <inheritdoc/>
- public string Name => "MigrateActivityLogDatabase";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var logLevelDictionary = new Dictionary<string, LogLevel>(StringComparer.OrdinalIgnoreCase)
@@ -61,9 +55,25 @@ namespace Jellyfin.Server.Migrations.Routines
};
var dataPath = _paths.DataPath;
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
+ var activityLogPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(activityLogPath))
+ {
+ _logger.LogWarning("{ActivityLogDb} doesn't exist, nothing to migrate", activityLogPath);
+ return;
+ }
+
+ using (var connection = new SqliteConnection($"Filename={activityLogPath}"))
{
connection.Open();
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ActivityLog';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'ActivityLog' doesn't exist in {ActivityLogPath}, nothing to migrate", activityLogPath);
+ return;
+ }
+ }
using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}");
userDbConnection.Open();
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
index feaf46c84..0de775e03 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs
@@ -15,7 +15,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// A migration that moves data from the authentication database into the new schema.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T14:00:00", nameof(MigrateAuthenticationDb), "5BD72F41-E6F3-4F60-90AA-09869ABE0E22")]
public class MigrateAuthenticationDb : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private const string DbFilename = "authentication.db";
@@ -44,21 +47,31 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("5BD72F41-E6F3-4F60-90AA-09869ABE0E22");
-
- /// <inheritdoc />
- public string Name => "MigrateAuthenticationDatabase";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
var dataPath = _appPaths.DataPath;
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
+ var dbFilePath = Path.Combine(dataPath, DbFilename);
+
+ if (!File.Exists(dbFilePath))
+ {
+ _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
+ return;
+ }
+
+ using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{
connection.Open();
+
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Tokens';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'Tokens' doesn't exist in {Path}, nothing to migrate", dbFilePath);
+ return;
+ }
+ }
+
using var dbContext = _dbProvider.CreateDbContext();
var authenticatedDevices = connection.Query("SELECT * FROM Tokens");
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
index a8fa2e52a..ffd06fea0 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
@@ -20,7 +20,10 @@ namespace Jellyfin.Server.Migrations.Routines
/// <summary>
/// The migration routine for migrating the display preferences database to EF Core.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+ [JellyfinMigration("2025-04-20T12:00:00", nameof(MigrateDisplayPreferencesDb), "06387815-C3CC-421F-A888-FB5F9992BEA8")]
public class MigrateDisplayPreferencesDb : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private const string DbFilename = "displaypreferences.db";
@@ -52,15 +55,6 @@ namespace Jellyfin.Server.Migrations.Routines
}
/// <inheritdoc />
- public Guid Id => Guid.Parse("06387815-C3CC-421F-A888-FB5F9992BEA8");
-
- /// <inheritdoc />
- public string Name => "MigrateDisplayPreferencesDatabase";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
HomeSectionType[] defaults =
@@ -84,9 +78,27 @@ namespace Jellyfin.Server.Migrations.Routines
var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
+
+ if (!File.Exists(dbFilePath))
+ {
+ _logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
+ return;
+ }
+
using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{
connection.Open();
+
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='userdisplaypreferences';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'userdisplaypreferences' doesn't exist in {Path}, nothing to migrate", dbFilePath);
+ return;
+ }
+ }
+
using var dbContext = _provider.CreateDbContext();
var results = connection.Query("SELECT * FROM userdisplaypreferences");
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
index b8e69db8e..aa5530926 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateKeyframeData.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -10,10 +9,9 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions.Json;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -22,10 +20,10 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to move extracted files to the new directories.
/// </summary>
+[JellyfinMigration("2025-04-21T00:00:00", nameof(MigrateKeyframeData))]
public class MigrateKeyframeData : IDatabaseMigrationRoutine
{
- private readonly ILibraryManager _libraryManager;
- private readonly ILogger<MoveTrickplayFiles> _logger;
+ private readonly IStartupLogger _logger;
private readonly IApplicationPaths _appPaths;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
@@ -33,18 +31,15 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
/// <summary>
/// Initializes a new instance of the <see cref="MigrateKeyframeData"/> class.
/// </summary>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- /// <param name="logger">The logger.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="dbProvider">The EFCore db factory.</param>
public MigrateKeyframeData(
- ILibraryManager libraryManager,
- ILogger<MoveTrickplayFiles> logger,
+ IStartupLogger<MigrateKeyframeData> startupLogger,
IApplicationPaths appPaths,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
- _libraryManager = libraryManager;
- _logger = logger;
+ _logger = startupLogger;
_appPaths = appPaths;
_dbProvider = dbProvider;
}
@@ -52,59 +47,41 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
private string KeyframeCachePath => Path.Combine(_appPaths.DataPath, "keyframes");
/// <inheritdoc />
- public Guid Id => new("EA4bCAE1-09A4-428E-9B90-4B4FD2EA1B24");
-
- /// <inheritdoc />
- public string Name => "MigrateKeyframeData";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
public void Perform()
{
- const int Limit = 100;
- int itemCount = 0, offset = 0, previousCount;
+ const int Limit = 5000;
+ int itemCount = 0, offset = 0;
var sw = Stopwatch.StartNew();
- var itemsQuery = new InternalItemsQuery
- {
- MediaTypes = [MediaType.Video],
- SourceTypes = [SourceType.Library],
- IsVirtualItem = false,
- IsFolder = false
- };
using var context = _dbProvider.CreateDbContext();
+ var baseQuery = context.BaseItems.Where(b => b.MediaType == MediaType.Video.ToString() && !b.IsVirtualItem && !b.IsFolder).OrderBy(e => e.Id);
+ var records = baseQuery.Count();
+ _logger.LogInformation("Checking {Count} items for importable keyframe data.", records);
+
context.KeyframeData.ExecuteDelete();
using var transaction = context.Database.BeginTransaction();
- List<KeyframeData> keyframes = [];
-
do
{
- var result = _libraryManager.GetItemsResult(itemsQuery);
- _logger.LogInformation("Importing keyframes for {Count} items", result.TotalRecordCount);
-
- var items = result.Items;
- previousCount = items.Count;
- offset += Limit;
- foreach (var item in items)
+ var results = baseQuery.Skip(offset).Take(Limit).Select(b => new Tuple<Guid, string?>(b.Id, b.Path)).ToList();
+ foreach (var result in results)
{
- if (TryGetKeyframeData(item, out var data))
+ if (TryGetKeyframeData(result.Item1, result.Item2, out var data))
{
- keyframes.Add(data);
+ itemCount++;
+ context.KeyframeData.Add(data);
}
+ }
- if (++itemCount % 10_000 == 0)
- {
- context.KeyframeData.AddRange(keyframes);
- keyframes.Clear();
- _logger.LogInformation("Imported keyframes for {Count} items in {Time}", itemCount, sw.Elapsed);
- }
+ offset += Limit;
+ if (offset > records)
+ {
+ offset = records;
}
- } while (previousCount == Limit);
- context.KeyframeData.AddRange(keyframes);
+ _logger.LogInformation("Checked: {Count} - Imported: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
+ } while (offset < records);
+
context.SaveChanges();
transaction.Commit();
@@ -116,10 +93,9 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
}
}
- private bool TryGetKeyframeData(BaseItem item, [NotNullWhen(true)] out KeyframeData? data)
+ private bool TryGetKeyframeData(Guid id, string? path, [NotNullWhen(true)] out KeyframeData? data)
{
data = null;
- var path = item.Path;
if (!string.IsNullOrEmpty(path))
{
var cachePath = GetCachePath(KeyframeCachePath, path);
@@ -127,7 +103,7 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
{
data = new()
{
- ItemId = item.Id,
+ ItemId = id,
KeyframeTicks = keyframeData.KeyframeTicks.ToList(),
TotalDuration = keyframeData.TotalDuration
};
@@ -146,6 +122,16 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
{
lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath);
}
+ catch (ArgumentOutOfRangeException e)
+ {
+ _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
+ return null;
+ }
+ catch (UnauthorizedAccessException e)
+ {
+ _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
+ return null;
+ }
catch (IOException e)
{
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
@@ -159,14 +145,21 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
return Path.Join(keyframeCachePath, prefix, filename);
}
- private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
+ private bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
{
if (File.Exists(cachePath))
{
- var bytes = File.ReadAllBytes(cachePath);
- cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
+ try
+ {
+ var bytes = File.ReadAllBytes(cachePath);
+ cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
- return cachedResult is not null;
+ return cachedResult is not null;
+ }
+ catch (JsonException jsonException)
+ {
+ _logger.LogWarning(jsonException, "Failed to read {Path}", cachePath);
+ }
}
cachedResult = null;
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
index 105fd555f..d221d1853 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDb.cs
@@ -9,12 +9,12 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
-using System.Threading;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using Jellyfin.Server.Implementations.Item;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Entities;
@@ -29,11 +29,13 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// The migration routine for migrating the userdata database to EF Core.
/// </summary>
+[JellyfinMigration("2025-04-20T20:00:00", nameof(MigrateLibraryDb))]
+[JellyfinMigrationBackup(JellyfinDb = true, LegacyLibraryDb = true)]
internal class MigrateLibraryDb : IDatabaseMigrationRoutine
{
private const string DbFilename = "library.db";
- private readonly ILogger<MigrateLibraryDb> _logger;
+ private readonly IStartupLogger _logger;
private readonly IServerApplicationPaths _paths;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly IDbContextFactory<JellyfinDbContext> _provider;
@@ -41,38 +43,35 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
/// <summary>
/// Initializes a new instance of the <see cref="MigrateLibraryDb"/> class.
/// </summary>
- /// <param name="logger">The logger.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
/// <param name="provider">The database provider.</param>
/// <param name="paths">The server application paths.</param>
/// <param name="jellyfinDatabaseProvider">The database provider for special access.</param>
public MigrateLibraryDb(
- ILogger<MigrateLibraryDb> logger,
+ IStartupLogger<MigrateLibraryDb> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths,
IJellyfinDatabaseProvider jellyfinDatabaseProvider)
{
- _logger = logger;
+ _logger = startupLogger;
_provider = provider;
_paths = paths;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("36445464-849f-429f-9ad0-bb130efa0664");
-
- /// <inheritdoc/>
- public string Name => "MigrateLibraryDbData";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false; // TODO Change back after testing
-
- /// <inheritdoc/>
public void Perform()
{
_logger.LogInformation("Migrating the userdata from library.db may take a while, do not stop Jellyfin.");
var dataPath = _paths.DataPath;
var libraryDbPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(libraryDbPath))
+ {
+ _logger.LogError("Cannot migrate {LibraryDb} as it does not exist..", libraryDbPath);
+ return;
+ }
+
using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
var fullOperationTimer = new Stopwatch();
@@ -91,12 +90,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
operation.JellyfinDbContext.AncestorIds.ExecuteDelete();
}
+ // notify the other migration to just silently abort because the fix has been applied here already.
+ ReseedFolderFlag.RerunGuardFlag = true;
+
var legacyBaseItemWithUserKeys = new Dictionary<string, BaseItemEntity>();
connection.Open();
var baseItemIds = new HashSet<Guid>();
- using (var operation = GetPreparedDbContext("moving TypedBaseItem"))
+ using (var operation = GetPreparedDbContext("Moving TypedBaseItem"))
{
+ IDictionary<Guid, (BaseItemEntity BaseItem, string[] Keys)> allItemsLookup = new Dictionary<Guid, (BaseItemEntity BaseItem, string[] Keys)>();
const string typedBaseItemsQuery =
"""
SELECT guid, type, data, StartDate, EndDate, ChannelId, IsMovie,
@@ -106,29 +109,68 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
Audio, ExternalServiceId, IsInMixedFolder, DateLastSaved, LockedFields, Studios, Tags, TrailerTypes, OriginalTitle, PrimaryVersionId,
DateLastMediaAdded, Album, LUFS, NormalizationGain, CriticRating, IsVirtualItem, SeriesName, UserDataKey, SeasonName, SeasonId, SeriesId,
PresentationUniqueKey, InheritedParentalRatingValue, ExternalSeriesId, Tagline, ProviderIds, Images, ProductionLocations, ExtraIds, TotalBitrate,
- ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType FROM TypedBaseItems
+ ExtraType, Artists, AlbumArtists, ExternalId, SeriesPresentationUniqueKey, ShowId, OwnerId, MediaType, SortName, CleanName, UnratedType, IsFolder FROM TypedBaseItems
""";
using (new TrackedMigrationStep("Loading TypedBaseItems", _logger))
{
foreach (SqliteDataReader dto in connection.Query(typedBaseItemsQuery))
{
var baseItem = GetItem(dto);
- operation.JellyfinDbContext.BaseItems.Add(baseItem.BaseItem);
- baseItemIds.Add(baseItem.BaseItem.Id);
- foreach (var dataKey in baseItem.LegacyUserDataKey)
+ allItemsLookup.Add(baseItem.BaseItem.Id, baseItem);
+ }
+ }
+
+ bool DoesResolve(Guid? parentId, HashSet<(BaseItemEntity BaseItem, string[] Keys)> checkStack)
+ {
+ if (parentId is null)
+ {
+ return true;
+ }
+
+ if (!allItemsLookup.TryGetValue(parentId.Value, out var parent))
+ {
+ return false; // item is detached and has no root anymore.
+ }
+
+ if (!checkStack.Add(parent))
+ {
+ return false; // recursive structure. Abort.
+ }
+
+ return DoesResolve(parent.BaseItem.ParentId, checkStack);
+ }
+
+ using (new TrackedMigrationStep("Clean TypedBaseItems hierarchy", _logger))
+ {
+ var checkStack = new HashSet<(BaseItemEntity BaseItem, string[] Keys)>();
+
+ foreach (var item in allItemsLookup)
+ {
+ var cachedItem = item.Value;
+ if (DoesResolve(cachedItem.BaseItem.ParentId, checkStack))
{
- legacyBaseItemWithUserKeys[dataKey] = baseItem.BaseItem;
+ checkStack.Add(cachedItem);
+ operation.JellyfinDbContext.BaseItems.Add(cachedItem.BaseItem);
+ baseItemIds.Add(cachedItem.BaseItem.Id);
+ foreach (var dataKey in cachedItem.Keys)
+ {
+ legacyBaseItemWithUserKeys[dataKey] = cachedItem.BaseItem;
+ }
}
+
+ checkStack.Clear();
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.BaseItems.Local.Count} BaseItem entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
+
+ allItemsLookup.Clear();
}
- using (var operation = GetPreparedDbContext("moving ItemValues"))
+ using (var operation = GetPreparedDbContext("Moving ItemValues"))
{
// do not migrate inherited types as they are now properly mapped in search and lookup.
const string itemValueQuery =
@@ -139,11 +181,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
// EFCores local lookup sucks. We cannot use context.ItemValues.Local here because its just super slow.
var localItems = new Dictionary<(int Type, string Value), (Database.Implementations.Entities.ItemValue ItemValue, List<Guid> ItemIds)>();
- using (new TrackedMigrationStep("loading ItemValues", _logger))
+ using (new TrackedMigrationStep("Loading ItemValues", _logger))
{
foreach (SqliteDataReader dto in connection.Query(itemValueQuery))
{
var itemId = dto.GetGuid(0);
+ if (!baseItemIds.Contains(itemId))
+ {
+ continue;
+ }
+
var entity = GetItemValue(dto);
var key = ((int)entity.Type, entity.Value);
if (!localItems.TryGetValue(key, out var existing))
@@ -167,13 +214,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.ItemValues.Local.Count} ItemValues entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving UserData"))
+ using (var operation = GetPreparedDbContext("Moving UserData"))
{
var queryResult = connection.Query(
"""
@@ -182,14 +229,14 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
""");
- using (new TrackedMigrationStep("loading UserData", _logger))
+ using (new TrackedMigrationStep("Loading UserData", _logger))
{
- var users = operation.JellyfinDbContext.Users.AsNoTracking().ToImmutableArray();
+ var users = operation.JellyfinDbContext.Users.AsNoTracking().ToArray();
var userIdBlacklist = new HashSet<int>();
foreach (var entity in queryResult)
{
- var userData = GetUserData(users, entity, userIdBlacklist);
+ var userData = GetUserData(users, entity, userIdBlacklist, _logger);
if (userData is null)
{
var userDataId = entity.GetString(0);
@@ -210,22 +257,25 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
continue;
}
+ if (!baseItemIds.Contains(refItem.Id))
+ {
+ continue;
+ }
+
userData.ItemId = refItem.Id;
operation.JellyfinDbContext.UserData.Add(userData);
}
-
- users.Clear();
}
legacyBaseItemWithUserKeys.Clear();
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.UserData.Local.Count} UserData entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving MediaStreamInfos"))
+ using (var operation = GetPreparedDbContext("Moving MediaStreamInfos"))
{
const string mediaStreamQuery =
"""
@@ -238,21 +288,27 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = MediaStreams.ItemId)
""";
- using (new TrackedMigrationStep("loading MediaStreamInfos", _logger))
+ using (new TrackedMigrationStep("Loading MediaStreamInfos", _logger))
{
foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery))
{
- operation.JellyfinDbContext.MediaStreamInfos.Add(GetMediaStream(dto));
+ var entity = GetMediaStream(dto);
+ if (!baseItemIds.Contains(entity.ItemId))
+ {
+ continue;
+ }
+
+ operation.JellyfinDbContext.MediaStreamInfos.Add(entity);
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.MediaStreamInfos.Local.Count} MediaStreamInfos entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving AttachmentStreamInfos"))
+ using (var operation = GetPreparedDbContext("Moving AttachmentStreamInfos"))
{
const string mediaAttachmentQuery =
"""
@@ -261,45 +317,51 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = mediaattachments.ItemId)
""";
- using (new TrackedMigrationStep("loading AttachmentStreamInfos", _logger))
+ using (new TrackedMigrationStep("Loading AttachmentStreamInfos", _logger))
{
foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery))
{
- operation.JellyfinDbContext.AttachmentStreamInfos.Add(GetMediaAttachment(dto));
+ var entity = GetMediaAttachment(dto);
+ if (!baseItemIds.Contains(entity.ItemId))
+ {
+ continue;
+ }
+
+ operation.JellyfinDbContext.AttachmentStreamInfos.Add(entity);
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AttachmentStreamInfos.Local.Count} AttachmentStreamInfos entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving People"))
+ using (var operation = GetPreparedDbContext("Moving People"))
{
const string personsQuery =
"""
- SELECT ItemId, Name, Role, PersonType, SortOrder FROM People
+ SELECT ItemId, Name, Role, PersonType, SortOrder, ListOrder FROM People
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = People.ItemId)
""";
var peopleCache = new Dictionary<string, (People Person, List<PeopleBaseItemMap> Items)>();
- using (new TrackedMigrationStep("loading People", _logger))
+ using (new TrackedMigrationStep("Loading People", _logger))
{
foreach (SqliteDataReader reader in connection.Query(personsQuery))
{
var itemId = reader.GetGuid(0);
if (!baseItemIds.Contains(itemId))
{
- _logger.LogError("Dont save person {0} because its not in use by any BaseItem", reader.GetString(1));
+ _logger.LogError("Not saving person {0} because it's not in use by any BaseItem", reader.GetString(1));
continue;
}
var entity = GetPerson(reader);
- if (!peopleCache.TryGetValue(entity.Name, out var personCache))
+ if (!peopleCache.TryGetValue(entity.Name + "|" + entity.PersonType, out var personCache))
{
- peopleCache[entity.Name] = personCache = (entity, []);
+ peopleCache[entity.Name + "|" + entity.PersonType] = personCache = (entity, []);
}
if (reader.TryGetString(2, out var role))
@@ -307,6 +369,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}
int? sortOrder = reader.IsDBNull(4) ? null : reader.GetInt32(4);
+ int? listOrder = reader.IsDBNull(5) ? null : reader.GetInt32(5);
personCache.Items.Add(new PeopleBaseItemMap()
{
@@ -314,14 +377,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
ItemId = itemId,
People = null!,
PeopleId = personCache.Person.Id,
- ListOrder = sortOrder,
+ ListOrder = listOrder,
SortOrder = sortOrder,
Role = role
});
}
- baseItemIds.Clear();
-
foreach (var item in peopleCache)
{
operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
@@ -331,13 +392,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
peopleCache.Clear();
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Peoples.Local.Count} People entries and {operation.JellyfinDbContext.PeopleBaseItemMap.Local.Count} maps", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving Chapters"))
+ using (var operation = GetPreparedDbContext("Moving Chapters"))
{
const string chapterQuery =
"""
@@ -345,22 +406,27 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
WHERE EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = Chapters2.ItemId)
""";
- using (new TrackedMigrationStep("loading Chapters", _logger))
+ using (new TrackedMigrationStep("Loading Chapters", _logger))
{
foreach (SqliteDataReader dto in connection.Query(chapterQuery))
{
var chapter = GetChapter(dto);
+ if (!baseItemIds.Contains(chapter.ItemId))
+ {
+ continue;
+ }
+
operation.JellyfinDbContext.Chapters.Add(chapter);
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.Chapters.Local.Count} Chapters entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
}
- using (var operation = GetPreparedDbContext("moving AncestorIds"))
+ using (var operation = GetPreparedDbContext("Moving AncestorIds"))
{
const string ancestorIdsQuery =
"""
@@ -371,16 +437,21 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.guid = AncestorIds.AncestorId)
""";
- using (new TrackedMigrationStep("loading AncestorIds", _logger))
+ using (new TrackedMigrationStep("Loading AncestorIds", _logger))
{
foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery))
{
var ancestorId = GetAncestorId(dto);
+ if (!baseItemIds.Contains(ancestorId.ItemId) || !baseItemIds.Contains(ancestorId.ParentItemId))
+ {
+ continue;
+ }
+
operation.JellyfinDbContext.AncestorIds.Add(ancestorId);
}
}
- using (new TrackedMigrationStep($"saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger))
+ using (new TrackedMigrationStep($"Saving {operation.JellyfinDbContext.AncestorIds.Local.Count} AncestorId entries", _logger))
{
operation.JellyfinDbContext.SaveChanges();
}
@@ -395,8 +466,6 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
File.Move(libraryDbPath, libraryDbPath + ".old", true);
-
- _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false).GetAwaiter().GetResult();
}
private DatabaseMigrationStep GetPreparedDbContext(string operationName)
@@ -407,19 +476,20 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
return new DatabaseMigrationStep(dbContext, operationName, _logger);
}
- private UserData? GetUserData(ImmutableArray<User> users, SqliteDataReader dto, HashSet<int> userIdBlacklist)
+ internal static UserData? GetUserData(User[] users, SqliteDataReader dto, HashSet<int> userIdBlacklist, ILogger logger)
{
var internalUserId = dto.GetInt32(1);
- var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
+ if (userIdBlacklist.Contains(internalUserId))
+ {
+ return null;
+ }
+ var user = users.FirstOrDefault(e => e.InternalId == internalUserId);
if (user is null)
{
- if (userIdBlacklist.Contains(internalUserId))
- {
- return null;
- }
+ userIdBlacklist.Add(internalUserId);
- _logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length);
+ logger.LogError("Tried to find user with index '{Idx}' but there are only '{MaxIdx}' users.", internalUserId, users.Length);
return null;
}
@@ -1087,12 +1157,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
if (reader.TryGetString(index++, out var providerIds))
{
- entity.Provider = providerIds.Split('|').Select(e => e.Split("="))
+ entity.Provider = providerIds.Split('|').Select(e => e.Split("=")).Where(e => e.Length >= 2)
.Select(e => new BaseItemProvider()
{
Item = null!,
ProviderId = e[0],
- ProviderValue = e[1]
+ ProviderValue = string.Join('|', e.Skip(1))
}).ToArray();
}
@@ -1171,7 +1241,12 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
entity.UnratedType = unratedType;
}
- var baseItem = BaseItemRepository.DeserialiseBaseItem(entity, _logger, null, false);
+ if (reader.TryGetBoolean(index++, out var isFolder))
+ {
+ entity.IsFolder = isFolder;
+ }
+
+ var baseItem = BaseItemRepository.DeserializeBaseItem(entity, _logger, null, false);
var dataKeys = baseItem.GetUserDataKeys();
userDataKeys.AddRange(dataKeys);
@@ -1185,7 +1260,7 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
ItemId = baseItemId,
Id = Guid.NewGuid(),
Path = e.Path,
- Blurhash = e.BlurHash != null ? Encoding.UTF8.GetBytes(e.BlurHash) : null,
+ Blurhash = e.BlurHash is not null ? Encoding.UTF8.GetBytes(e.BlurHash) : null,
DateModified = e.DateModified,
Height = e.Height,
Width = e.Width,
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs
new file mode 100644
index 000000000..d4cc9bbee
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryDbCompatibilityCheck.cs
@@ -0,0 +1,73 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller;
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// The migration routine for checking if the current instance of Jellyfin is compatiable to be upgraded.
+/// </summary>
+[JellyfinMigration("2025-04-20T19:30:00", nameof(MigrateLibraryDbCompatibilityCheck))]
+public class MigrateLibraryDbCompatibilityCheck : IAsyncMigrationRoutine
+{
+ private const string DbFilename = "library.db";
+ private readonly IStartupLogger _logger;
+ private readonly IServerApplicationPaths _paths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MigrateLibraryDbCompatibilityCheck"/> class.
+ /// </summary>
+ /// <param name="startupLogger">The startup logger.</param>
+ /// <param name="paths">The Path service.</param>
+ public MigrateLibraryDbCompatibilityCheck(IStartupLogger<MigrateLibraryDbCompatibilityCheck> startupLogger, IServerApplicationPaths paths)
+ {
+ _logger = startupLogger;
+ _paths = paths;
+ }
+
+ /// <inheritdoc/>
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ var dataPath = _paths.DataPath;
+ var libraryDbPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(libraryDbPath))
+ {
+ _logger.LogError("Cannot migrate {LibraryDb} as it does not exist..", libraryDbPath);
+ return;
+ }
+
+ using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+ CheckMigratableVersion(connection);
+ await connection.CloseAsync().ConfigureAwait(false);
+ }
+
+ private static void CheckMigratableVersion(SqliteConnection connection)
+ {
+ CheckColumnExistance(connection, "TypedBaseItems", "lufs");
+ CheckColumnExistance(connection, "TypedBaseItems", "normalizationgain");
+ CheckColumnExistance(connection, "mediastreams", "dvversionmajor");
+
+ static void CheckColumnExistance(SqliteConnection connection, string table, string column)
+ {
+ using (var cmd = connection.CreateCommand())
+ {
+#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
+ cmd.CommandText = $"Select COUNT(1) FROM pragma_table_xinfo('{table}') WHERE lower(name) = '{column}';";
+#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
+ var result = cmd.ExecuteScalar()!;
+ if (!result.Equals(1L))
+ {
+ throw new InvalidOperationException("Your database does not meet the required standard. Only upgrades from server version 10.9.11 or above are supported. Please upgrade first to server version 10.10.7 before attempting to upgrade afterwards to 10.11");
+ }
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs b/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs
new file mode 100644
index 000000000..8a0a1741f
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLibraryUserData.cs
@@ -0,0 +1,123 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+[JellyfinMigration("2025-06-18T01:00:00", nameof(MigrateLibraryUserData))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+internal class MigrateLibraryUserData : IAsyncMigrationRoutine
+{
+ private const string DbFilename = "library.db.old";
+
+ private readonly IStartupLogger _logger;
+ private readonly IServerApplicationPaths _paths;
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+
+ public MigrateLibraryUserData(
+ IStartupLogger<MigrateLibraryDb> startupLogger,
+ IDbContextFactory<JellyfinDbContext> provider,
+ IServerApplicationPaths paths)
+ {
+ _logger = startupLogger;
+ _provider = provider;
+ _paths = paths;
+ }
+
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Migrating the userdata from library.db.old may take a while, do not stop Jellyfin.");
+
+ var dataPath = _paths.DataPath;
+ var libraryDbPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(libraryDbPath))
+ {
+ _logger.LogError("Cannot migrate userdata from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath);
+ return;
+ }
+
+ var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ if (!await dbContext.BaseItems.AnyAsync(e => e.Id == BaseItemRepository.PlaceholderId, cancellationToken).ConfigureAwait(false))
+ {
+ // the placeholder baseitem has been deleted by the librarydb migration so we need to readd it.
+ await dbContext.BaseItems.AddAsync(
+ new Database.Implementations.Entities.BaseItemEntity()
+ {
+ Id = BaseItemRepository.PlaceholderId,
+ Type = "PLACEHOLDER",
+ Name = "This is a placeholder item for UserData that has been detacted from its original item"
+ },
+ cancellationToken)
+ .ConfigureAwait(false);
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ var users = dbContext.Users.AsNoTracking().ToArray();
+ var userIdBlacklist = new HashSet<int>();
+ using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
+ var retentionDate = DateTime.UtcNow;
+
+ var queryResult = connection.Query(
+"""
+ SELECT key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex FROM UserDatas
+
+ WHERE NOT EXISTS(SELECT 1 FROM TypedBaseItems WHERE TypedBaseItems.UserDataKey = UserDatas.key)
+""");
+
+ var importedUserData = new Dictionary<Guid, List<UserData>>();
+ foreach (var entity in queryResult)
+ {
+ var userData = MigrateLibraryDb.GetUserData(users, entity, userIdBlacklist, _logger);
+ if (userData is null)
+ {
+ var userDataId = entity.GetString(0);
+ var internalUserId = entity.GetInt32(1);
+
+ if (!userIdBlacklist.Contains(internalUserId))
+ {
+ _logger.LogError("Was not able to migrate user data with key {0} because its id {InternalId} does not match any existing user.", userDataId, internalUserId);
+ userIdBlacklist.Add(internalUserId);
+ }
+
+ continue;
+ }
+
+ var ogId = userData.ItemId;
+ userData.ItemId = BaseItemRepository.PlaceholderId;
+ userData.RetentionDate = retentionDate;
+ if (!importedUserData.TryGetValue(ogId, out var importUserData))
+ {
+ importUserData = [];
+ importedUserData[ogId] = importUserData;
+ }
+
+ importUserData.Add(userData);
+ }
+
+ foreach (var item in importedUserData)
+ {
+ await dbContext.UserData.Where(e => e.ItemId == item.Key).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
+ dbContext.UserData.AddRange(item.Value.DistinctBy(e => e.CustomDataKey)); // old userdata can have fucked up duplicates
+ }
+
+ _logger.LogInformation("Try saving {NewSaved} UserData entries.", dbContext.UserData.Local.Count);
+ await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
index c38beb723..2a6db01cf 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
@@ -1,74 +1,69 @@
using System;
using System.Linq;
using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Model.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.Server.Migrations.Routines
-{
- /// <summary>
- /// Migrate rating levels.
- /// </summary>
- internal class MigrateRatingLevels : IDatabaseMigrationRoutine
- {
- private readonly ILogger<MigrateRatingLevels> _logger;
- private readonly IDbContextFactory<JellyfinDbContext> _provider;
- private readonly ILocalizationManager _localizationManager;
+namespace Jellyfin.Server.Migrations.Routines;
- public MigrateRatingLevels(
- IDbContextFactory<JellyfinDbContext> provider,
- ILoggerFactory loggerFactory,
- ILocalizationManager localizationManager)
- {
- _provider = provider;
- _localizationManager = localizationManager;
- _logger = loggerFactory.CreateLogger<MigrateRatingLevels>();
- }
-
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("{98724538-EB11-40E3-931A-252C55BDDE7A}");
-
- /// <inheritdoc/>
- public string Name => "MigrateRatingLevels";
+/// <summary>
+/// Migrate rating levels.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T22:00:00", nameof(MigrateRatingLevels))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+#pragma warning restore CS0618 // Type or member is obsolete
+internal class MigrateRatingLevels : IDatabaseMigrationRoutine
+{
+ private readonly IStartupLogger _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+ private readonly ILocalizationManager _localizationManager;
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
+ public MigrateRatingLevels(
+ IDbContextFactory<JellyfinDbContext> provider,
+ IStartupLogger<MigrateRatingLevels> logger,
+ ILocalizationManager localizationManager)
+ {
+ _provider = provider;
+ _localizationManager = localizationManager;
+ _logger = logger;
+ }
- /// <inheritdoc/>
- public void Perform()
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ _logger.LogInformation("Recalculating parental rating levels based on rating string.");
+ using var context = _provider.CreateDbContext();
+ using var transaction = context.Database.BeginTransaction();
+ var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct();
+ foreach (var rating in ratings)
{
- _logger.LogInformation("Recalculating parental rating levels based on rating string.");
- using var context = _provider.CreateDbContext();
- using var transaction = context.Database.BeginTransaction();
- var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct();
- foreach (var rating in ratings)
+ if (string.IsNullOrEmpty(rating))
{
- if (string.IsNullOrEmpty(rating))
- {
- int? value = null;
- context.BaseItems
- .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
- .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value));
- context.BaseItems
- .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
- .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value));
- }
- else
- {
- var ratingValue = _localizationManager.GetRatingScore(rating);
- var score = ratingValue?.Score;
- var subScore = ratingValue?.SubScore;
- context.BaseItems
- .Where(e => e.OfficialRating == rating)
- .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score));
- context.BaseItems
- .Where(e => e.OfficialRating == rating)
- .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore));
- }
+ int? value = null;
+ context.BaseItems
+ .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
+ .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, value));
+ context.BaseItems
+ .Where(e => e.OfficialRating == null || e.OfficialRating == string.Empty)
+ .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, value));
+ }
+ else
+ {
+ var ratingValue = _localizationManager.GetRatingScore(rating);
+ var score = ratingValue?.Score;
+ var subScore = ratingValue?.SubScore;
+ context.BaseItems
+ .Where(e => e.OfficialRating == rating)
+ .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingValue, score));
+ context.BaseItems
+ .Where(e => e.OfficialRating == rating)
+ .ExecuteUpdate(f => f.SetProperty(e => e.InheritedParentalRatingSubValue, subScore));
}
-
- transaction.Commit();
}
+
+ transaction.Commit();
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
index 1b5fab7a8..8c3361ee1 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
@@ -17,207 +17,217 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using JsonSerializer = System.Text.Json.JsonSerializer;
-namespace Jellyfin.Server.Migrations.Routines
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// The migration routine for migrating the user database to EF Core.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T10:00:00", nameof(MigrateUserDb), "5C4B82A2-F053-4009-BD05-B6FCAD82F14C")]
+public class MigrateUserDb : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
+ private const string DbFilename = "users.db";
+
+ private readonly ILogger<MigrateUserDb> _logger;
+ private readonly IServerApplicationPaths _paths;
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+ private readonly IXmlSerializer _xmlSerializer;
+
/// <summary>
- /// The migration routine for migrating the user database to EF Core.
+ /// Initializes a new instance of the <see cref="MigrateUserDb"/> class.
/// </summary>
- public class MigrateUserDb : IMigrationRoutine
+ /// <param name="logger">The logger.</param>
+ /// <param name="paths">The server application paths.</param>
+ /// <param name="provider">The database provider.</param>
+ /// <param name="xmlSerializer">The xml serializer.</param>
+ public MigrateUserDb(
+ ILogger<MigrateUserDb> logger,
+ IServerApplicationPaths paths,
+ IDbContextFactory<JellyfinDbContext> provider,
+ IXmlSerializer xmlSerializer)
+ {
+ _logger = logger;
+ _paths = paths;
+ _provider = provider;
+ _xmlSerializer = xmlSerializer;
+ }
+
+ /// <inheritdoc/>
+ public void Perform()
{
- private const string DbFilename = "users.db";
-
- private readonly ILogger<MigrateUserDb> _logger;
- private readonly IServerApplicationPaths _paths;
- private readonly IDbContextFactory<JellyfinDbContext> _provider;
- private readonly IXmlSerializer _xmlSerializer;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="MigrateUserDb"/> class.
- /// </summary>
- /// <param name="logger">The logger.</param>
- /// <param name="paths">The server application paths.</param>
- /// <param name="provider">The database provider.</param>
- /// <param name="xmlSerializer">The xml serializer.</param>
- public MigrateUserDb(
- ILogger<MigrateUserDb> logger,
- IServerApplicationPaths paths,
- IDbContextFactory<JellyfinDbContext> provider,
- IXmlSerializer xmlSerializer)
+ var dataPath = _paths.DataPath;
+ var userDbPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(userDbPath))
{
- _logger = logger;
- _paths = paths;
- _provider = provider;
- _xmlSerializer = xmlSerializer;
+ _logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath);
+ return;
}
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("5C4B82A2-F053-4009-BD05-B6FCAD82F14C");
+ _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
- /// <inheritdoc/>
- public string Name => "MigrateUserDatabase";
+ using (var connection = new SqliteConnection($"Filename={userDbPath}"))
+ {
+ connection.Open();
+ var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';");
+ foreach (var row in tableQuery)
+ {
+ if (row.GetInt32(0) == 0)
+ {
+ _logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath);
+ return;
+ }
+ }
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
+ using var dbContext = _provider.CreateDbContext();
- /// <inheritdoc/>
- public void Perform()
- {
- var dataPath = _paths.DataPath;
- _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
+ var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
- using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
- {
- connection.Open();
- using var dbContext = _provider.CreateDbContext();
+ dbContext.RemoveRange(dbContext.Users);
+ dbContext.SaveChanges();
- var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
+ foreach (var entry in queryResult)
+ {
+ UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry.GetStream(2), JsonDefaults.Options);
+ if (mockup is null)
+ {
+ continue;
+ }
- dbContext.RemoveRange(dbContext.Users);
- dbContext.SaveChanges();
+ var userDataDir = Path.Combine(_paths.UserConfigurationDirectoryPath, mockup.Name);
+
+ var configPath = Path.Combine(userDataDir, "config.xml");
+ var config = File.Exists(configPath)
+ ? (UserConfiguration?)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), configPath) ?? new UserConfiguration()
+ : new UserConfiguration();
+
+ var policyPath = Path.Combine(userDataDir, "policy.xml");
+ var policy = File.Exists(policyPath)
+ ? (UserPolicy?)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), policyPath) ?? new UserPolicy()
+ : new UserPolicy();
+ policy.AuthenticationProviderId = policy.AuthenticationProviderId?.Replace(
+ "Emby.Server.Implementations.Library",
+ "Jellyfin.Server.Implementations.Users",
+ StringComparison.Ordinal)
+ ?? typeof(DefaultAuthenticationProvider).FullName;
+
+ policy.PasswordResetProviderId = typeof(DefaultPasswordResetProvider).FullName;
+ int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
+ {
+ -1 => null,
+ 0 => 3,
+ _ => policy.LoginAttemptsBeforeLockout
+ };
- foreach (var entry in queryResult)
+ var user = new User(mockup.Name, policy.AuthenticationProviderId!, policy.PasswordResetProviderId!)
{
- UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry.GetStream(2), JsonDefaults.Options);
- if (mockup is null)
- {
- continue;
- }
-
- var userDataDir = Path.Combine(_paths.UserConfigurationDirectoryPath, mockup.Name);
-
- var configPath = Path.Combine(userDataDir, "config.xml");
- var config = File.Exists(configPath)
- ? (UserConfiguration?)_xmlSerializer.DeserializeFromFile(typeof(UserConfiguration), configPath) ?? new UserConfiguration()
- : new UserConfiguration();
-
- var policyPath = Path.Combine(userDataDir, "policy.xml");
- var policy = File.Exists(policyPath)
- ? (UserPolicy?)_xmlSerializer.DeserializeFromFile(typeof(UserPolicy), policyPath) ?? new UserPolicy()
- : new UserPolicy();
- policy.AuthenticationProviderId = policy.AuthenticationProviderId?.Replace(
- "Emby.Server.Implementations.Library",
- "Jellyfin.Server.Implementations.Users",
- StringComparison.Ordinal)
- ?? typeof(DefaultAuthenticationProvider).FullName;
-
- policy.PasswordResetProviderId = typeof(DefaultPasswordResetProvider).FullName;
- int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
- {
- -1 => null,
- 0 => 3,
- _ => policy.LoginAttemptsBeforeLockout
- };
+ Id = entry.GetGuid(1),
+ InternalId = entry.GetInt64(0),
+ MaxParentalRatingScore = policy.MaxParentalRating,
+ MaxParentalRatingSubScore = null,
+ EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess,
+ RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit,
+ InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount,
+ LoginAttemptsBeforeLockout = maxLoginAttempts,
+ SubtitleMode = config.SubtitleMode,
+ HidePlayedInLatest = config.HidePlayedInLatest,
+ EnableLocalPassword = config.EnableLocalPassword,
+ PlayDefaultAudioTrack = config.PlayDefaultAudioTrack,
+ DisplayCollectionsView = config.DisplayCollectionsView,
+ DisplayMissingEpisodes = config.DisplayMissingEpisodes,
+ AudioLanguagePreference = config.AudioLanguagePreference,
+ RememberAudioSelections = config.RememberAudioSelections,
+ EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay,
+ RememberSubtitleSelections = config.RememberSubtitleSelections,
+ SubtitleLanguagePreference = config.SubtitleLanguagePreference,
+ Password = mockup.Password,
+ LastLoginDate = mockup.LastLoginDate,
+ LastActivityDate = mockup.LastActivityDate
+ };
+
+ if (mockup.ImageInfos.Length > 0)
+ {
+ ItemImageInfo info = mockup.ImageInfos[0];
- var user = new User(mockup.Name, policy.AuthenticationProviderId!, policy.PasswordResetProviderId!)
+ user.ProfileImage = new ImageInfo(info.Path)
{
- Id = entry.GetGuid(1),
- InternalId = entry.GetInt64(0),
- MaxParentalRatingScore = policy.MaxParentalRating,
- MaxParentalRatingSubScore = null,
- EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess,
- RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit,
- InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount,
- LoginAttemptsBeforeLockout = maxLoginAttempts,
- SubtitleMode = config.SubtitleMode,
- HidePlayedInLatest = config.HidePlayedInLatest,
- EnableLocalPassword = config.EnableLocalPassword,
- PlayDefaultAudioTrack = config.PlayDefaultAudioTrack,
- DisplayCollectionsView = config.DisplayCollectionsView,
- DisplayMissingEpisodes = config.DisplayMissingEpisodes,
- AudioLanguagePreference = config.AudioLanguagePreference,
- RememberAudioSelections = config.RememberAudioSelections,
- EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay,
- RememberSubtitleSelections = config.RememberSubtitleSelections,
- SubtitleLanguagePreference = config.SubtitleLanguagePreference,
- Password = mockup.Password,
- LastLoginDate = mockup.LastLoginDate,
- LastActivityDate = mockup.LastActivityDate
+ LastModified = info.DateModified
};
+ }
- if (mockup.ImageInfos.Length > 0)
- {
- ItemImageInfo info = mockup.ImageInfos[0];
-
- user.ProfileImage = new ImageInfo(info.Path)
- {
- LastModified = info.DateModified
- };
- }
-
- user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
- user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
- user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
- user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
- user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
- user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
- user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
- user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
- user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
- user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
- user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
- user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
- user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
- user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
- user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
- user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
- user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
- user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
- user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
- user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
- user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
- user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
-
- foreach (var policyAccessSchedule in policy.AccessSchedules)
- {
- user.AccessSchedules.Add(policyAccessSchedule);
- }
-
- user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
- user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
- user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
- user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
- user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
- user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
- user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
- user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
- user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
-
- dbContext.Users.Add(user);
+ user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
+ user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
+ user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
+ user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
+ user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
+ user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
+ user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
+ user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
+ user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
+ user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
+ user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
+ user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
+ user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
+ user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
+ user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
+ user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
+ user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
+ user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
+ user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
+ user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
+ user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
+ user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
+
+ foreach (var policyAccessSchedule in policy.AccessSchedules)
+ {
+ user.AccessSchedules.Add(policyAccessSchedule);
}
- dbContext.SaveChanges();
+ user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
+ user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
+ user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
+ user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
+ user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
+ user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
+ user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
+ user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
+ user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
+
+ dbContext.Users.Add(user);
}
- try
- {
- File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old"));
+ dbContext.SaveChanges();
+ }
- var journalPath = Path.Combine(dataPath, DbFilename + "-journal");
- if (File.Exists(journalPath))
- {
- File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal"));
- }
- }
- catch (IOException e)
+ try
+ {
+ File.Move(Path.Combine(dataPath, DbFilename), Path.Combine(dataPath, DbFilename + ".old"));
+
+ var journalPath = Path.Combine(dataPath, DbFilename + "-journal");
+ if (File.Exists(journalPath))
{
- _logger.LogError(e, "Error renaming legacy user database to 'users.db.old'");
+ File.Move(journalPath, Path.Combine(dataPath, DbFilename + ".old-journal"));
}
}
+ catch (IOException e)
+ {
+ _logger.LogError(e, "Error renaming legacy user database to 'users.db.old'");
+ }
+ }
#nullable disable
- internal class UserMockup
- {
- public string Password { get; set; }
+ internal class UserMockup
+ {
+ public string Password { get; set; }
- public string EasyPassword { get; set; }
+ public string EasyPassword { get; set; }
- public DateTime? LastLoginDate { get; set; }
+ public DateTime? LastLoginDate { get; set; }
- public DateTime? LastActivityDate { get; set; }
+ public DateTime? LastActivityDate { get; set; }
- public string Name { get; set; }
+ public string Name { get; set; }
- public ItemImageInfo[] ImageInfos { get; set; }
- }
+ public ItemImageInfo[] ImageInfos { get; set; }
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
index f63c5fd40..fbf9c1637 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
@@ -1,20 +1,24 @@
#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
using System;
+using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.IO;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
@@ -22,34 +26,37 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to move extracted files to the new directories.
/// </summary>
-public class MoveExtractedFiles : IDatabaseMigrationRoutine
+[JellyfinMigration("2025-04-20T21:00:00", nameof(MoveExtractedFiles))]
+public class MoveExtractedFiles : IAsyncMigrationRoutine
{
private readonly IApplicationPaths _appPaths;
- private readonly ILibraryManager _libraryManager;
- private readonly ILogger<MoveExtractedFiles> _logger;
- private readonly IMediaSourceManager _mediaSourceManager;
+ private readonly ILogger _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IPathManager _pathManager;
+ private readonly IFileSystem _fileSystem;
/// <summary>
/// Initializes a new instance of the <see cref="MoveExtractedFiles"/> class.
/// </summary>
/// <param name="appPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="logger">The logger.</param>
- /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
+ /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="pathManager">Instance of the <see cref="IPathManager"/> interface.</param>
+ /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
public MoveExtractedFiles(
IApplicationPaths appPaths,
- ILibraryManager libraryManager,
ILogger<MoveExtractedFiles> logger,
- IMediaSourceManager mediaSourceManager,
- IPathManager pathManager)
+ IStartupLogger<MoveExtractedFiles> startupLogger,
+ IPathManager pathManager,
+ IFileSystem fileSystem,
+ IDbContextFactory<JellyfinDbContext> dbProvider)
{
_appPaths = appPaths;
- _libraryManager = libraryManager;
- _logger = logger;
- _mediaSourceManager = mediaSourceManager;
+ _logger = startupLogger.With(logger);
_pathManager = pathManager;
+ _fileSystem = fileSystem;
+ _dbProvider = dbProvider;
}
private string SubtitleCachePath => Path.Combine(_appPaths.DataPath, "subtitles");
@@ -57,62 +64,43 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
/// <inheritdoc />
- public Guid Id => new("9063b0Ef-CFF1-4EDC-9A13-74093681A89B");
-
- /// <inheritdoc />
- public string Name => "MoveExtractedFiles";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc />
- public void Perform()
+ public async Task PerformAsync(CancellationToken cancellationToken)
{
- const int Limit = 500;
- int itemCount = 0, offset = 0;
+ const int Limit = 5000;
+ int itemCount = 0;
var sw = Stopwatch.StartNew();
- var itemsQuery = new InternalItemsQuery
- {
- MediaTypes = [MediaType.Video],
- SourceTypes = [SourceType.Library],
- IsVirtualItem = false,
- IsFolder = false,
- Limit = Limit,
- StartIndex = offset,
- EnableTotalRecordCount = true,
- };
-
- var records = _libraryManager.GetItemsResult(itemsQuery).TotalRecordCount;
+
+ using var context = _dbProvider.CreateDbContext();
+ var records = context.BaseItems.Count(b => b.MediaType == MediaType.Video.ToString() && !b.IsVirtualItem && !b.IsFolder);
_logger.LogInformation("Checking {Count} items for movable extracted files.", records);
// Make sure directories exist
Directory.CreateDirectory(SubtitleCachePath);
Directory.CreateDirectory(AttachmentCachePath);
- itemsQuery.EnableTotalRecordCount = false;
- do
+ await foreach (var result in context.BaseItems
+ .Include(e => e.MediaStreams!.Where(s => s.StreamType == MediaStreamTypeEntity.Subtitle && !s.IsExternal))
+ .Where(b => b.MediaType == MediaType.Video.ToString() && !b.IsVirtualItem && !b.IsFolder)
+ .Select(b => new
+ {
+ b.Id,
+ b.Path,
+ b.MediaStreams
+ })
+ .OrderBy(e => e.Id)
+ .WithPartitionProgress((partition) => _logger.LogInformation("Checked: {Count} - Moved: {Items} - Time: {Time}", partition * Limit, itemCount, sw.Elapsed))
+ .PartitionEagerAsync(Limit, cancellationToken)
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
{
- itemsQuery.StartIndex = offset;
- var result = _libraryManager.GetItemsResult(itemsQuery);
-
- var items = result.Items;
- foreach (var item in items)
+ if (MoveSubtitleAndAttachmentFiles(result.Id, result.Path, result.MediaStreams, context))
{
- if (MoveSubtitleAndAttachmentFiles(item))
- {
- itemCount++;
- }
+ itemCount++;
}
+ }
- offset += Limit;
- if (offset % 5_000 == 0)
- {
- _logger.LogInformation("Checked extracted files for {Count} items in {Time}.", offset, sw.Elapsed);
- }
- } while (offset < records);
-
- _logger.LogInformation("Checked {Checked} items - Moved files for {Items} items in {Time}.", records, itemCount, sw.Elapsed);
+ _logger.LogInformation("Moved files for {Count} items in {Time}", itemCount, sw.Elapsed);
// Get all subdirectories with 1 character names (those are the legacy directories)
var subdirectories = Directory.GetDirectories(SubtitleCachePath, "*", SearchOption.AllDirectories).Where(s => s.Length == SubtitleCachePath.Length + 2).ToList();
@@ -134,52 +122,56 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
_logger.LogInformation("Cleaned up left over subtitles and attachments.");
}
- private bool MoveSubtitleAndAttachmentFiles(BaseItem item)
+ private bool MoveSubtitleAndAttachmentFiles(Guid id, string? path, ICollection<MediaStreamInfo>? mediaStreams, JellyfinDbContext context)
{
- var mediaStreams = item.GetMediaStreams().Where(s => s.Type == MediaStreamType.Subtitle && !s.IsExternal);
- var itemIdString = item.Id.ToString("N", CultureInfo.InvariantCulture);
+ var itemIdString = id.ToString("N", CultureInfo.InvariantCulture);
var modified = false;
- foreach (var mediaStream in mediaStreams)
+ if (mediaStreams is not null)
{
- if (mediaStream.Codec is null)
+ foreach (var mediaStream in mediaStreams)
{
- continue;
- }
-
- var mediaStreamIndex = mediaStream.Index;
- var extension = GetSubtitleExtension(mediaStream.Codec);
- var oldSubtitleCachePath = GetOldSubtitleCachePath(item.Path, mediaStream.Index, extension);
- if (string.IsNullOrEmpty(oldSubtitleCachePath) || !File.Exists(oldSubtitleCachePath))
- {
- continue;
- }
+ if (mediaStream.Codec is null)
+ {
+ continue;
+ }
- var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension);
- if (File.Exists(newSubtitleCachePath))
- {
- File.Delete(oldSubtitleCachePath);
- }
- else
- {
- var newDirectory = Path.GetDirectoryName(newSubtitleCachePath);
- if (newDirectory is not null)
+ var mediaStreamIndex = mediaStream.StreamIndex;
+ var extension = GetSubtitleExtension(mediaStream.Codec);
+ var oldSubtitleCachePath = GetOldSubtitleCachePath(path, mediaStreamIndex, extension);
+ if (string.IsNullOrEmpty(oldSubtitleCachePath) || !File.Exists(oldSubtitleCachePath))
{
- Directory.CreateDirectory(newDirectory);
- File.Move(oldSubtitleCachePath, newSubtitleCachePath, false);
- _logger.LogDebug("Moved subtitle {Index} for {Item} from {Source} to {Destination}", mediaStreamIndex, item.Id, oldSubtitleCachePath, newSubtitleCachePath);
+ continue;
+ }
- modified = true;
+ var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension);
+ if (File.Exists(newSubtitleCachePath))
+ {
+ File.Delete(oldSubtitleCachePath);
+ }
+ else
+ {
+ var newDirectory = Path.GetDirectoryName(newSubtitleCachePath);
+ if (newDirectory is not null)
+ {
+ Directory.CreateDirectory(newDirectory);
+ File.Move(oldSubtitleCachePath, newSubtitleCachePath, false);
+ _logger.LogDebug("Moved subtitle {Index} for {Item} from {Source} to {Destination}", mediaStreamIndex, id, oldSubtitleCachePath, newSubtitleCachePath);
+
+ modified = true;
+ }
}
}
}
- var attachments = _mediaSourceManager.GetMediaAttachments(item.Id).Where(a => !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)).ToList();
- var shouldExtractOneByOne = attachments.Any(a => !string.IsNullOrEmpty(a.FileName)
- && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
+#pragma warning disable CA1309 // Use ordinal string comparison
+ var attachments = context.AttachmentStreamInfos.Where(a => a.ItemId.Equals(id) && !string.Equals(a.Codec, "mjpeg")).ToList();
+#pragma warning restore CA1309 // Use ordinal string comparison
+ var shouldExtractOneByOne = attachments.Any(a => !string.IsNullOrEmpty(a.Filename)
+ && (a.Filename.Contains('/', StringComparison.OrdinalIgnoreCase) || a.Filename.Contains('\\', StringComparison.OrdinalIgnoreCase)));
foreach (var attachment in attachments)
{
var attachmentIndex = attachment.Index;
- var oldAttachmentPath = GetOldAttachmentDataPath(item.Path, attachmentIndex);
+ var oldAttachmentPath = GetOldAttachmentDataPath(path, attachmentIndex);
if (string.IsNullOrEmpty(oldAttachmentPath) || !File.Exists(oldAttachmentPath))
{
oldAttachmentPath = GetOldAttachmentCachePath(itemIdString, attachment, shouldExtractOneByOne);
@@ -189,7 +181,7 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
}
}
- var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.FileName ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
+ var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
if (File.Exists(newAttachmentPath))
{
File.Delete(oldAttachmentPath);
@@ -201,7 +193,7 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
{
Directory.CreateDirectory(newDirectory);
File.Move(oldAttachmentPath, newAttachmentPath, false);
- _logger.LogDebug("Moved attachment {Index} for {Item} from {Source} to {Destination}", attachmentIndex, item.Id, oldAttachmentPath, newAttachmentPath);
+ _logger.LogDebug("Moved attachment {Index} for {Item} from {Source} to {Destination}", attachmentIndex, id, oldAttachmentPath, newAttachmentPath);
modified = true;
}
@@ -219,8 +211,7 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
}
string filename;
- var protocol = _mediaSourceManager.GetPathProtocol(mediaPath);
- if (protocol == MediaProtocol.File)
+ if (_fileSystem.IsPathFile(mediaPath))
{
DateTime? date;
try
@@ -233,6 +224,18 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
return null;
}
+ catch (UnauthorizedAccessException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
+
+ return null;
+ }
+ catch (ArgumentOutOfRangeException e)
+ {
+ _logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
+
+ return null;
+ }
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
}
@@ -244,7 +247,7 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
return Path.Join(_appPaths.DataPath, "attachments", filename[..1], filename);
}
- private string? GetOldAttachmentCachePath(string mediaSourceId, MediaAttachment attachment, bool shouldExtractOneByOne)
+ private string? GetOldAttachmentCachePath(string mediaSourceId, AttachmentStreamInfo attachment, bool shouldExtractOneByOne)
{
var attachmentFolderPath = Path.Join(_appPaths.CachePath, "attachments", mediaSourceId);
if (shouldExtractOneByOne)
@@ -252,21 +255,38 @@ public class MoveExtractedFiles : IDatabaseMigrationRoutine
return Path.Join(attachmentFolderPath, attachment.Index.ToString(CultureInfo.InvariantCulture));
}
- if (string.IsNullOrEmpty(attachment.FileName))
+ if (string.IsNullOrEmpty(attachment.Filename))
{
return null;
}
- return Path.Join(attachmentFolderPath, attachment.FileName);
+ return Path.Join(attachmentFolderPath, attachment.Filename);
}
- private string? GetOldSubtitleCachePath(string path, int streamIndex, string outputSubtitleExtension)
+ private string? GetOldSubtitleCachePath(string? path, int streamIndex, string outputSubtitleExtension)
{
+ if (path is null)
+ {
+ return null;
+ }
+
DateTime? date;
try
{
date = File.GetLastWriteTimeUtc(path);
}
+ catch (ArgumentOutOfRangeException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
+
+ return null;
+ }
+ catch (UnauthorizedAccessException e)
+ {
+ _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
+
+ return null;
+ }
catch (IOException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
diff --git a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
index eeb11e14c..0f55465e8 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveTrickplayFiles.cs
@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using Jellyfin.Data.Enums;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Trickplay;
@@ -15,12 +16,15 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to move trickplay files to the new directory.
/// </summary>
-public class MoveTrickplayFiles : IDatabaseMigrationRoutine
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T23:00:00", nameof(MoveTrickplayFiles), RunMigrationOnSetup = true)]
+public class MoveTrickplayFiles : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ITrickplayManager _trickplayManager;
private readonly IFileSystem _fileSystem;
private readonly ILibraryManager _libraryManager;
- private readonly ILogger<MoveTrickplayFiles> _logger;
+ private readonly IStartupLogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MoveTrickplayFiles"/> class.
@@ -29,7 +33,11 @@ public class MoveTrickplayFiles : IDatabaseMigrationRoutine
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="logger">The logger.</param>
- public MoveTrickplayFiles(ITrickplayManager trickplayManager, IFileSystem fileSystem, ILibraryManager libraryManager, ILogger<MoveTrickplayFiles> logger)
+ public MoveTrickplayFiles(
+ ITrickplayManager trickplayManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IStartupLogger<MoveTrickplayFiles> logger)
{
_trickplayManager = trickplayManager;
_fileSystem = fileSystem;
@@ -38,18 +46,9 @@ public class MoveTrickplayFiles : IDatabaseMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => new("9540D44A-D8DC-11EF-9CBB-B77274F77C52");
-
- /// <inheritdoc />
- public string Name => "MoveTrickplayFiles";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => true;
-
- /// <inheritdoc />
public void Perform()
{
- const int Limit = 100;
+ const int Limit = 5000;
int itemCount = 0, offset = 0, previousCount;
var sw = Stopwatch.StartNew();
@@ -64,9 +63,6 @@ public class MoveTrickplayFiles : IDatabaseMigrationRoutine
do
{
var trickplayInfos = _trickplayManager.GetTrickplayItemsAsync(Limit, offset).GetAwaiter().GetResult();
- previousCount = trickplayInfos.Count;
- offset += Limit;
-
trickplayQuery.ItemIds = trickplayInfos.Select(i => i.ItemId).Distinct().ToArray();
var items = _libraryManager.GetItemList(trickplayQuery);
foreach (var trickplayInfo in trickplayInfos)
@@ -77,24 +73,32 @@ public class MoveTrickplayFiles : IDatabaseMigrationRoutine
continue;
}
- if (++itemCount % 1_000 == 0)
- {
- _logger.LogInformation("Moved {Count} items in {Time}", itemCount, sw.Elapsed);
- }
-
+ var moved = false;
var oldPath = GetOldTrickplayDirectory(item, trickplayInfo.Width);
var newPath = _trickplayManager.GetTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false);
if (_fileSystem.DirectoryExists(oldPath))
{
_fileSystem.MoveDirectory(oldPath, newPath);
+ moved = true;
}
oldPath = GetNewOldTrickplayDirectory(item, trickplayInfo.TileWidth, trickplayInfo.TileHeight, trickplayInfo.Width, false);
if (_fileSystem.DirectoryExists(oldPath))
{
_fileSystem.MoveDirectory(oldPath, newPath);
+ moved = true;
+ }
+
+ if (moved)
+ {
+ itemCount++;
}
}
+
+ offset += Limit;
+ previousCount = trickplayInfos.Count;
+
+ _logger.LogInformation("Checked: {Checked} - Moved: {Count} - Time: {Time}", offset, itemCount, sw.Elapsed);
} while (previousCount == Limit);
_logger.LogInformation("Moved {Count} items in {Time}", itemCount, sw.Elapsed);
diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
index 9cfaec46f..ebf4a2780 100644
--- a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
@@ -2,48 +2,41 @@ using System;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates;
-namespace Jellyfin.Server.Migrations.Routines
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to initialize system configuration with the default plugin repository.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T11:00:00", nameof(ReaddDefaultPluginRepository), "5F86E7F6-D966-4C77-849D-7A7B40B68C4E", RunMigrationOnSetup = true)]
+public class ReaddDefaultPluginRepository : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ private readonly RepositoryInfo _defaultRepositoryInfo = new RepositoryInfo
+ {
+ Name = "Jellyfin Stable",
+ Url = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json"
+ };
+
/// <summary>
- /// Migration to initialize system configuration with the default plugin repository.
+ /// Initializes a new instance of the <see cref="ReaddDefaultPluginRepository"/> class.
/// </summary>
- public class ReaddDefaultPluginRepository : IMigrationRoutine
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public ReaddDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager)
{
- private readonly IServerConfigurationManager _serverConfigurationManager;
-
- private readonly RepositoryInfo _defaultRepositoryInfo = new RepositoryInfo
- {
- Name = "Jellyfin Stable",
- Url = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json"
- };
-
- /// <summary>
- /// Initializes a new instance of the <see cref="ReaddDefaultPluginRepository"/> class.
- /// </summary>
- /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
- public ReaddDefaultPluginRepository(IServerConfigurationManager serverConfigurationManager)
- {
- _serverConfigurationManager = serverConfigurationManager;
- }
-
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("5F86E7F6-D966-4C77-849D-7A7B40B68C4E");
-
- /// <inheritdoc/>
- public string Name => "ReaddDefaultPluginRepository";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => true;
+ _serverConfigurationManager = serverConfigurationManager;
+ }
- /// <inheritdoc/>
- public void Perform()
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ // Only add if repository list is empty
+ if (_serverConfigurationManager.Configuration.PluginRepositories.Length == 0)
{
- // Only add if repository list is empty
- if (_serverConfigurationManager.Configuration.PluginRepositories.Length == 0)
- {
- _serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo };
- _serverConfigurationManager.SaveConfiguration();
- }
+ _serverConfigurationManager.Configuration.PluginRepositories = new[] { _defaultRepositoryInfo };
+ _serverConfigurationManager.SaveConfiguration();
}
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs b/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs
new file mode 100644
index 000000000..b23a7dbc4
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/RefreshInternalDateModified.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Extensions;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.IO;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Migration to re-read creation dates for library items with internal metadata paths.
+/// </summary>
+[JellyfinMigration("2025-04-20T23:00:00", nameof(RefreshInternalDateModified))]
+public class RefreshInternalDateModified : IDatabaseMigrationRoutine
+{
+ private readonly ILogger<RefreshInternalDateModified> _logger;
+ private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
+ private readonly IFileSystem _fileSystem;
+ private readonly IServerApplicationHost _applicationHost;
+ private readonly bool _useFileCreationTimeForDateAdded;
+
+ private IReadOnlyList<string> _internalTypes = [
+ typeof(Genre).FullName!,
+ typeof(MusicGenre).FullName!,
+ typeof(MusicArtist).FullName!,
+ typeof(People).FullName!,
+ typeof(Studio).FullName!
+ ];
+
+ private IReadOnlyList<string> _internalPaths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RefreshInternalDateModified"/> class.
+ /// </summary>
+ /// <param name="applicationHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param>
+ /// <param name="applicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param>
+ /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ /// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ public RefreshInternalDateModified(
+ IServerApplicationHost applicationHost,
+ IServerApplicationPaths applicationPaths,
+ IServerConfigurationManager configurationManager,
+ IDbContextFactory<JellyfinDbContext> dbProvider,
+ ILogger<RefreshInternalDateModified> logger,
+ IFileSystem fileSystem)
+ {
+ _dbProvider = dbProvider;
+ _logger = logger;
+ _fileSystem = fileSystem;
+ _applicationHost = applicationHost;
+ _internalPaths = [
+ applicationPaths.ArtistsPath,
+ applicationPaths.GenrePath,
+ applicationPaths.MusicGenrePath,
+ applicationPaths.StudioPath,
+ applicationPaths.PeoplePath
+ ];
+ _useFileCreationTimeForDateAdded = configurationManager.GetMetadataConfiguration().UseFileCreationTimeForDateAdded;
+ }
+
+ /// <inheritdoc />
+ public void Perform()
+ {
+ const int Limit = 5000;
+ int itemCount = 0, offset = 0;
+
+ var sw = Stopwatch.StartNew();
+
+ using var context = _dbProvider.CreateDbContext();
+ var records = context.BaseItems.Count(b => _internalTypes.Contains(b.Type));
+ _logger.LogInformation("Checking if {Count} potentially internal items require refreshed DateModified", records);
+
+ do
+ {
+ var results = context.BaseItems
+ .Where(b => _internalTypes.Contains(b.Type))
+ .OrderBy(e => e.Id)
+ .Skip(offset)
+ .Take(Limit)
+ .ToList();
+
+ foreach (var item in results)
+ {
+ var itemPath = item.Path;
+ if (itemPath is not null)
+ {
+ var realPath = _applicationHost.ExpandVirtualPath(item.Path);
+ if (_internalPaths.Any(path => realPath.StartsWith(path, StringComparison.Ordinal)))
+ {
+ var writeTime = _fileSystem.GetLastWriteTimeUtc(realPath);
+ var itemModificationTime = item.DateModified;
+ if (writeTime != itemModificationTime)
+ {
+ _logger.LogDebug("Reset file modification date: Old: {Old} - New: {New} - Path: {Path}", itemModificationTime, writeTime, realPath);
+ item.DateModified = writeTime;
+ if (_useFileCreationTimeForDateAdded)
+ {
+ item.DateCreated = _fileSystem.GetCreationTimeUtc(realPath);
+ }
+
+ itemCount++;
+ }
+ }
+ }
+ }
+
+ offset += Limit;
+ if (offset > records)
+ {
+ offset = records;
+ }
+
+ _logger.LogInformation("Checked: {Count} - Refreshed: {Items} - Time: {Time}", offset, itemCount, sw.Elapsed);
+ } while (offset < records);
+
+ context.SaveChanges();
+
+ _logger.LogInformation("Refreshed DateModified for {Count} items in {Time}", itemCount, sw.Elapsed);
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
index 52fb93d59..b626c473e 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
@@ -3,50 +3,43 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.Server.Migrations.Routines
-{
- /// <summary>
- /// Removes the old 'RemoveDownloadImagesInAdvance' from library options.
- /// </summary>
- internal class RemoveDownloadImagesInAdvance : IMigrationRoutine
- {
- private readonly ILogger<RemoveDownloadImagesInAdvance> _logger;
- private readonly ILibraryManager _libraryManager;
-
- public RemoveDownloadImagesInAdvance(ILogger<RemoveDownloadImagesInAdvance> logger, ILibraryManager libraryManager)
- {
- _logger = logger;
- _libraryManager = libraryManager;
- }
-
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("{A81F75E0-8F43-416F-A5E8-516CCAB4D8CC}");
+namespace Jellyfin.Server.Migrations.Routines;
- /// <inheritdoc/>
- public string Name => "RemoveDownloadImagesInAdvance";
+/// <summary>
+/// Removes the old 'RemoveDownloadImagesInAdvance' from library options.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T13:00:00", nameof(RemoveDownloadImagesInAdvance), "A81F75E0-8F43-416F-A5E8-516CCAB4D8CC")]
+internal class RemoveDownloadImagesInAdvance : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
+{
+ private readonly ILogger<RemoveDownloadImagesInAdvance> _logger;
+ private readonly ILibraryManager _libraryManager;
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
+ public RemoveDownloadImagesInAdvance(ILogger<RemoveDownloadImagesInAdvance> logger, ILibraryManager libraryManager)
+ {
+ _logger = logger;
+ _libraryManager = libraryManager;
+ }
- /// <inheritdoc/>
- public void Perform()
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ var virtualFolders = _libraryManager.GetVirtualFolders(false);
+ _logger.LogInformation("Removing 'RemoveDownloadImagesInAdvance' settings in all the libraries");
+ foreach (var virtualFolder in virtualFolders)
{
- var virtualFolders = _libraryManager.GetVirtualFolders(false);
- _logger.LogInformation("Removing 'RemoveDownloadImagesInAdvance' settings in all the libraries");
- foreach (var virtualFolder in virtualFolders)
+ // Some virtual folders don't have a proper item id.
+ if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
{
- // Some virtual folders don't have a proper item id.
- if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
- {
- continue;
- }
-
- var libraryOptions = virtualFolder.LibraryOptions;
- var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId) ?? throw new InvalidOperationException("Failed to find CollectionFolder");
- // The property no longer exists in LibraryOptions, so we just re-save the options to get old data removed.
- collectionFolder.UpdateLibraryOptions(libraryOptions);
- _logger.LogInformation("Removed from '{VirtualFolder}'", virtualFolder.Name);
+ continue;
}
+
+ var libraryOptions = virtualFolder.LibraryOptions;
+ var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId) ?? throw new InvalidOperationException("Failed to find CollectionFolder");
+ // The property no longer exists in LibraryOptions, so we just re-save the options to get old data removed.
+ collectionFolder.UpdateLibraryOptions(libraryOptions);
+ _logger.LogInformation("Removed from '{VirtualFolder}'", virtualFolder.Name);
}
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs
index 7b0d9456d..c9e66d0cf 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs
@@ -7,77 +7,70 @@ using MediaBrowser.Controller;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
-namespace Jellyfin.Server.Migrations.Routines
+namespace Jellyfin.Server.Migrations.Routines;
+
+/// <summary>
+/// Remove duplicate entries which were caused by a bug where a file was considered to be an "Extra" to itself.
+/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T08:00:00", nameof(RemoveDuplicateExtras), "ACBE17B7-8435-4A83-8B64-6FCF162CB9BD")]
+internal class RemoveDuplicateExtras : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
- /// <summary>
- /// Remove duplicate entries which were caused by a bug where a file was considered to be an "Extra" to itself.
- /// </summary>
- internal class RemoveDuplicateExtras : IMigrationRoutine
+ private const string DbFilename = "library.db";
+ private readonly ILogger<RemoveDuplicateExtras> _logger;
+ private readonly IServerApplicationPaths _paths;
+
+ public RemoveDuplicateExtras(ILogger<RemoveDuplicateExtras> logger, IServerApplicationPaths paths)
{
- private const string DbFilename = "library.db";
- private readonly ILogger<RemoveDuplicateExtras> _logger;
- private readonly IServerApplicationPaths _paths;
+ _logger = logger;
+ _paths = paths;
+ }
- public RemoveDuplicateExtras(ILogger<RemoveDuplicateExtras> logger, IServerApplicationPaths paths)
+ /// <inheritdoc/>
+ public void Perform()
+ {
+ var dataPath = _paths.DataPath;
+ var dbPath = Path.Combine(dataPath, DbFilename);
+ using var connection = new SqliteConnection($"Filename={dbPath}");
+ connection.Open();
+ using (var transaction = connection.BeginTransaction())
{
- _logger = logger;
- _paths = paths;
- }
+ // Query the database for the ids of duplicate extras
+ var queryResult = connection.Query("SELECT t1.Path FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video'");
+ var bads = string.Join(", ", queryResult.Select(x => x.GetString(0)));
- /// <inheritdoc/>
- public Guid Id => Guid.Parse("{ACBE17B7-8435-4A83-8B64-6FCF162CB9BD}");
-
- /// <inheritdoc/>
- public string Name => "RemoveDuplicateExtras";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
- public void Perform()
- {
- var dataPath = _paths.DataPath;
- var dbPath = Path.Combine(dataPath, DbFilename);
- using var connection = new SqliteConnection($"Filename={dbPath}");
- connection.Open();
- using (var transaction = connection.BeginTransaction())
+ // Do nothing if no duplicate extras were detected
+ if (bads.Length == 0)
{
- // Query the database for the ids of duplicate extras
- var queryResult = connection.Query("SELECT t1.Path FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video'");
- var bads = string.Join(", ", queryResult.Select(x => x.GetString(0)));
-
- // Do nothing if no duplicate extras were detected
- if (bads.Length == 0)
- {
- _logger.LogInformation("No duplicate extras detected, skipping migration.");
- return;
- }
+ _logger.LogInformation("No duplicate extras detected, skipping migration.");
+ return;
+ }
- // Back up the database before deleting any entries
- for (int i = 1; ; i++)
+ // Back up the database before deleting any entries
+ for (int i = 1; ; i++)
+ {
+ var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
+ if (!File.Exists(bakPath))
{
- var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
- if (!File.Exists(bakPath))
+ try
{
- try
- {
- File.Copy(dbPath, bakPath);
- _logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
- break;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
- throw;
- }
+ File.Copy(dbPath, bakPath);
+ _logger.LogInformation("Library database backed up to {BackupPath}", bakPath);
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
+ throw;
}
}
-
- // Delete all duplicate extras
- _logger.LogInformation("Removing found duplicated extras for the following items: {DuplicateExtras}", bads);
- connection.Execute("DELETE FROM TypedBaseItems WHERE rowid IN (SELECT t1.rowid FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video')");
- transaction.Commit();
}
+
+ // Delete all duplicate extras
+ _logger.LogInformation("Removing found duplicated extras for the following items: {DuplicateExtras}", bads);
+ connection.Execute("DELETE FROM TypedBaseItems WHERE rowid IN (SELECT t1.rowid FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video')");
+ transaction.Commit();
}
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
index e183a1d63..23f212424 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicatePlaylistChildren.cs
@@ -11,7 +11,10 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Remove duplicate playlist entries.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")]
internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ILibraryManager _libraryManager;
private readonly IPlaylistManager _playlistManager;
@@ -25,15 +28,6 @@ internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
}
/// <inheritdoc/>
- public Guid Id => Guid.Parse("{96C156A2-7A13-4B3B-A8B8-FB80C94D20C0}");
-
- /// <inheritdoc/>
- public string Name => "RemoveDuplicatePlaylistChildren";
-
- /// <inheritdoc/>
- public bool PerformOnNewInstall => false;
-
- /// <inheritdoc/>
public void Perform()
{
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
diff --git a/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs b/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs
new file mode 100644
index 000000000..502763ac0
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/ReseedFolderFlag.cs
@@ -0,0 +1,74 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Server.ServerSetupApp;
+using MediaBrowser.Controller;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines;
+
+[JellyfinMigration("2025-07-30T21:50:00", nameof(ReseedFolderFlag))]
+[JellyfinMigrationBackup(JellyfinDb = true)]
+internal class ReseedFolderFlag : IAsyncMigrationRoutine
+{
+ private const string DbFilename = "library.db.old";
+
+ private readonly IStartupLogger _logger;
+ private readonly IServerApplicationPaths _paths;
+ private readonly IDbContextFactory<JellyfinDbContext> _provider;
+
+ public ReseedFolderFlag(
+ IStartupLogger<MigrateLibraryDb> startupLogger,
+ IDbContextFactory<JellyfinDbContext> provider,
+ IServerApplicationPaths paths)
+ {
+ _logger = startupLogger;
+ _provider = provider;
+ _paths = paths;
+ }
+
+ internal static bool RerunGuardFlag { get; set; } = false;
+
+ public async Task PerformAsync(CancellationToken cancellationToken)
+ {
+ if (RerunGuardFlag)
+ {
+ _logger.LogInformation("Migration is skipped because it does not apply.");
+ return;
+ }
+
+ _logger.LogInformation("Migrating the IsFolder flag from library.db.old may take a while, do not stop Jellyfin.");
+
+ var dataPath = _paths.DataPath;
+ var libraryDbPath = Path.Combine(dataPath, DbFilename);
+ if (!File.Exists(libraryDbPath))
+ {
+ _logger.LogError("Cannot migrate IsFolder flag from {LibraryDb} as it does not exist. This migration expects the MigrateLibraryDb to run first.", libraryDbPath);
+ return;
+ }
+
+ var dbContext = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
+ using var connection = new SqliteConnection($"Filename={libraryDbPath};Mode=ReadOnly");
+ var queryResult = connection.Query(
+ """
+ SELECT guid FROM TypedBaseItems
+ WHERE IsFolder = true
+ """)
+ .Select(entity => entity.GetGuid(0))
+ .ToList();
+ _logger.LogInformation("Migrating the IsFolder flag for {Count} items.", queryResult.Count);
+ foreach (var id in queryResult)
+ {
+ await dbContext.BaseItems.Where(e => e.Id == id).ExecuteUpdateAsync(e => e.SetProperty(f => f.IsFolder, true), cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs
index 7e8c8ac87..f58cf2741 100644
--- a/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/UpdateDefaultPluginRepository.cs
@@ -6,7 +6,10 @@ namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to update the default Jellyfin plugin repository.
/// </summary>
+#pragma warning disable CS0618 // Type or member is obsolete
+[JellyfinMigration("2025-04-20T17:00:00", nameof(UpdateDefaultPluginRepository), "852816E0-2712-49A9-9240-C6FC5FCAD1A8", RunMigrationOnSetup = true)]
public class UpdateDefaultPluginRepository : IMigrationRoutine
+#pragma warning restore CS0618 // Type or member is obsolete
{
private const string NewRepositoryUrl = "https://repo.jellyfin.org/files/plugin/manifest.json";
private const string OldRepositoryUrl = "https://repo.jellyfin.org/releases/plugin/manifest-stable.json";
@@ -23,15 +26,6 @@ public class UpdateDefaultPluginRepository : IMigrationRoutine
}
/// <inheritdoc />
- public Guid Id => new("852816E0-2712-49A9-9240-C6FC5FCAD1A8");
-
- /// <inheritdoc />
- public string Name => "UpdateDefaultPluginRepository10.9";
-
- /// <inheritdoc />
- public bool PerformOnNewInstall => true;
-
- /// <inheritdoc />
public void Perform()
{
var updated = false;
diff --git a/Jellyfin.Server/Migrations/Stages/CodeMigration.cs b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
new file mode 100644
index 000000000..971b47608
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Stages/CodeMigration.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Globalization;
+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;
+
+internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute metadata, JellyfinMigrationBackupAttribute? migrationBackupAttribute)
+{
+ public Type MigrationType { get; } = migrationType;
+
+ public JellyfinMigrationAttribute Metadata { get; } = metadata;
+
+ public JellyfinMigrationBackupAttribute? BackupRequirements { get; set; } = migrationBackupAttribute;
+
+ public string BuildCodeMigrationId()
+ {
+ return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!;
+ }
+
+ private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
+ {
+ var childServiceCollection = new ServiceCollection()
+ .AddSingleton(serviceProvider)
+ .AddSingleton(logger)
+ .AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>))
+ .AddSingleton<StartupLogTopic>(logger.Topic!);
+
+ foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>())
+ {
+ if (service.Lifetime == ServiceLifetime.Singleton && !service.ServiceType.IsGenericTypeDefinition)
+ {
+ childServiceCollection.AddSingleton(service.ServiceType, _ => serviceProvider.GetService(service.ServiceType)!);
+ continue;
+ }
+
+ childServiceCollection.Add(service);
+ }
+
+ return childServiceCollection;
+ }
+
+ public async Task Perform(IServiceProvider? serviceProvider, IStartupLogger logger, CancellationToken cancellationToken)
+ {
+#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 class NestedStartupLogger<TCategory> : StartupLogger<TCategory>
+ {
+ public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic)
+ {
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs b/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs
new file mode 100644
index 000000000..3d5ec233b
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Stages/JellyfinMigrationStageTypes.cs
@@ -0,0 +1,26 @@
+namespace Jellyfin.Server.Migrations.Stages;
+
+/// <summary>
+/// Defines the stages the <see cref="JellyfinMigrationService"/> supports.
+/// </summary>
+#pragma warning disable CA1008 // Enums should have zero value
+public enum JellyfinMigrationStageTypes
+#pragma warning restore CA1008 // Enums should have zero value
+{
+ /// <summary>
+ /// Runs before services are initialised.
+ /// Reserved for migrations that are modifying the application server itself. Should be avoided if possible.
+ /// </summary>
+ PreInitialisation = 1,
+
+ /// <summary>
+ /// Runs after the host has been configured and includes the database migrations.
+ /// Allows the mix order of migrations that contain application code and database changes.
+ /// </summary>
+ CoreInitialisation = 2,
+
+ /// <summary>
+ /// Runs after services has been registered and initialised. Last step before running the server.
+ /// </summary>
+ AppInitialisation = 3
+}
diff --git a/Jellyfin.Server/Migrations/Stages/MigrationStage.cs b/Jellyfin.Server/Migrations/Stages/MigrationStage.cs
new file mode 100644
index 000000000..efcadbf00
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Stages/MigrationStage.cs
@@ -0,0 +1,16 @@
+using System.Collections.ObjectModel;
+
+namespace Jellyfin.Server.Migrations.Stages;
+
+/// <summary>
+/// Defines a Stage that can be Invoked and Handled at different times from the code.
+/// </summary>
+internal class MigrationStage : Collection<CodeMigration>
+{
+ public MigrationStage(JellyfinMigrationStageTypes stage)
+ {
+ Stage = stage;
+ }
+
+ public JellyfinMigrationStageTypes Stage { get; }
+}
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index 8d0bf73f6..93f71fdc6 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -9,16 +9,22 @@ using System.Threading;
using System.Threading.Tasks;
using CommandLine;
using Emby.Server.Implementations;
+using Emby.Server.Implementations.Configuration;
+using Emby.Server.Implementations.Serialization;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Helpers;
+using Jellyfin.Server.Implementations.DatabaseConfiguration;
+using Jellyfin.Server.Implementations.Extensions;
using Jellyfin.Server.Implementations.StorageHelpers;
+using Jellyfin.Server.Implementations.SystemBackupService;
+using Jellyfin.Server.Migrations;
+using Jellyfin.Server.Migrations.Stages;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -54,6 +60,8 @@ namespace Jellyfin.Server
private static long _startTimestamp;
private static ILogger _logger = NullLogger.Instance;
private static bool _restartOnShutdown;
+ private static IStartupLogger<JellyfinMigrationService>? _migrationLogger;
+ private static string? _restoreFromBackup;
/// <summary>
/// The entry point of the application.
@@ -75,8 +83,10 @@ namespace Jellyfin.Server
private static async Task StartApp(StartupOptions options)
{
+ _restoreFromBackup = options.RestoreArchive;
_startTimestamp = Stopwatch.GetTimestamp();
ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options);
+ appPaths.MakeSanityCheckOrThrow();
// $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager
Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath);
@@ -89,10 +99,11 @@ namespace Jellyfin.Server
// Create an instance of the application configuration to use for application startup
IConfiguration startupConfig = CreateAppConfiguration(options, appPaths);
+ StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths);
_setupServer = new SetupServer(static () => _jellyfinHost?.Services?.GetService<INetworkManager>(), appPaths, static () => _appHost, _loggerFactory, startupConfig);
await _setupServer.RunAsync().ConfigureAwait(false);
- StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths);
_logger = _loggerFactory.CreateLogger("Main");
+ StartupLogger.Logger = new StartupLogger(_logger);
// Use the logging framework for uncaught exceptions instead of std error
AppDomain.CurrentDomain.UnhandledException += (_, e)
@@ -122,10 +133,11 @@ namespace Jellyfin.Server
}
}
- StorageHelper.TestCommonPathsForStorageCapacity(appPaths, _loggerFactory.CreateLogger<Startup>());
+ StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
StartupHelpers.PerformStaticInitialization();
- await Migrations.MigrationRunner.RunPreStartup(appPaths, _loggerFactory).ConfigureAwait(false);
+
+ await ApplyStartupMigrationAsync(appPaths, startupConfig).ConfigureAwait(false);
do
{
@@ -150,6 +162,7 @@ namespace Jellyfin.Server
options,
startupConfig);
_appHost = appHost;
+ var configurationCompleted = false;
try
{
_jellyfinHost = Host.CreateDefaultBuilder()
@@ -166,16 +179,40 @@ namespace Jellyfin.Server
})
.ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig))
.UseSerilog()
+ .ConfigureServices(e => e
+ .RegisterStartupLogger()
+ .AddSingleton<IServiceCollection>(e))
.Build();
+ /*
+ * Initialize the transcode path marker so we avoid starting Jellyfin in a broken state.
+ * This should really be a part of IApplicationPaths but this path is configured differently.
+ */
+ _ = appHost.ConfigurationManager.GetTranscodePath();
+
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
appHost.ServiceProvider = _jellyfinHost.Services;
+ PrepareDatabaseProvider(appHost.ServiceProvider);
+
+ if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
+ {
+ await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
+ _restoreFromBackup = null;
+ _restartOnShutdown = true;
+ return;
+ }
+
+ var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
+ await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
+ await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
- await Migrations.MigrationRunner.Run(appHost, _loggerFactory).ConfigureAwait(false);
+ await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
+ await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false);
try
{
+ configurationCompleted = true;
await _setupServer!.StopAsync().ConfigureAwait(false);
await _jellyfinHost.StartAsync().ConfigureAwait(false);
@@ -198,11 +235,18 @@ namespace Jellyfin.Server
await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
_restartOnShutdown = appHost.ShouldRestart;
+ _restoreFromBackup = appHost.RestoreBackupPath;
}
catch (Exception ex)
{
_restartOnShutdown = false;
_logger.LogCritical(ex, "Error while starting server");
+ if (_setupServer!.IsAlive && !configurationCompleted)
+ {
+ _setupServer!.SoftStop();
+ await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false);
+ await _setupServer!.StopAsync().ConfigureAwait(false);
+ }
}
finally
{
@@ -223,6 +267,52 @@ namespace Jellyfin.Server
}
/// <summary>
+ /// [Internal]Runs the startup Migrations.
+ /// </summary>
+ /// <remarks>
+ /// Not intended to be used other then by jellyfin and its tests.
+ /// </remarks>
+ /// <param name="appPaths">Application Paths.</param>
+ /// <param name="startupConfig">Startup Config.</param>
+ /// <returns>A task.</returns>
+ public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig)
+ {
+ _migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service");
+ var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
+ startupConfigurationManager.AddParts([new DatabaseConfigurationFactory()]);
+ var migrationStartupServiceProvider = new ServiceCollection()
+ .AddLogging(d => d.AddSerilog())
+ .AddJellyfinDbContext(startupConfigurationManager, startupConfig)
+ .AddSingleton<IApplicationPaths>(appPaths)
+ .AddSingleton<ServerApplicationPaths>(appPaths)
+ .RegisterStartupLogger();
+
+ migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
+ var startupService = migrationStartupServiceProvider.BuildServiceProvider();
+
+ PrepareDatabaseProvider(startupService);
+
+ var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService);
+ await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths).ConfigureAwait(false);
+ await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// [Internal]Runs the Jellyfin migrator service with the Core stage.
+ /// </summary>
+ /// <remarks>
+ /// Not intended to be used other then by jellyfin and its tests.
+ /// </remarks>
+ /// <param name="serviceProvider">The service provider.</param>
+ /// <param name="jellyfinMigrationStage">The stage to run.</param>
+ /// <returns>A task.</returns>
+ 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);
+ }
+
+ /// <summary>
/// Create the application configuration.
/// </summary>
/// <param name="commandLineOpts">The command line options passed to the program.</param>
@@ -256,5 +346,12 @@ namespace Jellyfin.Server
.AddEnvironmentVariables("JELLYFIN_")
.AddInMemoryCollection(commandLineOpts.ConvertToConfig());
}
+
+ private static void PrepareDatabaseProvider(IServiceProvider services)
+ {
+ var factory = services.GetRequiredService<IDbContextFactory<JellyfinDbContext>>();
+ var provider = services.GetRequiredService<IJellyfinDatabaseProvider>();
+ provider.DbContextFactory = factory;
+ }
}
}
diff --git a/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs
new file mode 100644
index 000000000..e7c193936
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/IStartupLogger.cs
@@ -0,0 +1,66 @@
+using System;
+using ILogger = Microsoft.Extensions.Logging.ILogger;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <summary>
+/// Defines the Startup Logger. This logger acts an an aggregate logger that will push though all log messages to both the attached logger as well as the startup UI.
+/// </summary>
+public interface IStartupLogger : ILogger
+{
+ /// <summary>
+ /// Gets the topic this logger is assigned to.
+ /// </summary>
+ StartupLogTopic? Topic { get; }
+
+ /// <summary>
+ /// Adds another logger instance to this logger for combined logging.
+ /// </summary>
+ /// <param name="logger">Other logger to rely messages to.</param>
+ /// <returns>A combined logger.</returns>
+ IStartupLogger With(ILogger logger);
+
+ /// <summary>
+ /// Opens a new Group logger within the parent logger.
+ /// </summary>
+ /// <param name="logEntry">Defines the log message that introduces the new group.</param>
+ /// <returns>A new logger that can write to the group.</returns>
+ IStartupLogger BeginGroup(FormattableString logEntry);
+
+ /// <summary>
+ /// Adds another logger instance to this logger for combined logging.
+ /// </summary>
+ /// <param name="logger">Other logger to rely messages to.</param>
+ /// <returns>A combined logger.</returns>
+ /// <typeparam name="TCategory">The logger cateogry.</typeparam>
+ IStartupLogger<TCategory> With<TCategory>(ILogger logger);
+
+ /// <summary>
+ /// Opens a new Group logger within the parent logger.
+ /// </summary>
+ /// <param name="logEntry">Defines the log message that introduces the new group.</param>
+ /// <returns>A new logger that can write to the group.</returns>
+ /// <typeparam name="TCategory">The logger cateogry.</typeparam>
+ IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry);
+}
+
+/// <summary>
+/// Defines a logger that can be injected via DI to get a startup logger initialised with an logger framework connected <see cref="ILogger"/>.
+/// </summary>
+/// <typeparam name="TCategory">The logger cateogry.</typeparam>
+public interface IStartupLogger<TCategory> : IStartupLogger
+{
+ /// <summary>
+ /// Adds another logger instance to this logger for combined logging.
+ /// </summary>
+ /// <param name="logger">Other logger to rely messages to.</param>
+ /// <returns>A combined logger.</returns>
+ new IStartupLogger<TCategory> With(ILogger logger);
+
+ /// <summary>
+ /// Opens a new Group logger within the parent logger.
+ /// </summary>
+ /// <param name="logEntry">Defines the log message that introduces the new group.</param>
+ /// <returns>A new logger that can write to the group.</returns>
+ new IStartupLogger<TCategory> BeginGroup(FormattableString logEntry);
+}
diff --git a/Jellyfin.Server/ServerSetupApp/SetupServer.cs b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
index 3d4810bd7..4340969a3 100644
--- a/Jellyfin.Server/ServerSetupApp/SetupServer.cs
+++ b/Jellyfin.Server/ServerSetupApp/SetupServer.cs
@@ -1,4 +1,7 @@
using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
@@ -7,9 +10,11 @@ using System.Threading.Tasks;
using Emby.Server.Implementations.Configuration;
using Emby.Server.Implementations.Serialization;
using Jellyfin.Networking.Manager;
+using Jellyfin.Server.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
+using MediaBrowser.Model.IO;
using MediaBrowser.Model.System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -20,6 +25,11 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
+using Morestachio;
+using Morestachio.Framework.IO.SingleStream;
+using Morestachio.Rendering;
+using Serilog;
+using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace Jellyfin.Server.ServerSetupApp;
@@ -34,8 +44,10 @@ public sealed class SetupServer : IDisposable
private readonly ILoggerFactory _loggerFactory;
private readonly IConfiguration _startupConfiguration;
private readonly ServerConfigurationManager _configurationManager;
+ private IRenderer? _startupUiRenderer;
private IHost? _startupServer;
private bool _disposed;
+ private bool _isUnhealthy;
/// <summary>
/// Initializes a new instance of the <see cref="SetupServer"/> class.
@@ -62,26 +74,92 @@ public sealed class SetupServer : IDisposable
_configurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
}
+ internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
+
+ /// <summary>
+ /// Gets a value indicating whether Startup server is currently running.
+ /// </summary>
+ public bool IsAlive { get; internal set; }
+
/// <summary>
/// Starts the Bind-All Setup aspcore server to provide a reflection on the current core setup.
/// </summary>
/// <returns>A Task.</returns>
public async Task RunAsync()
{
+ var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
+ _startupUiRenderer = (await ParserOptionsBuilder.New()
+ .WithTemplate(fileTemplate)
+ .WithFormatter(
+ (StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
+ {
+ if (children.Any())
+ {
+ var maxLevel = logEntry.LogLevel;
+ var stack = new Stack<StartupLogTopic>(children);
+
+ while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
+ {
+ maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
+ foreach (var child in logEntry.Children)
+ {
+ stack.Push(child);
+ }
+ }
+
+ return maxLevel;
+ }
+
+ return logEntry.LogLevel;
+ },
+ "FormatLogLevel")
+ .WithFormatter(
+ (LogLevel logLevel) =>
+ {
+ switch (logLevel)
+ {
+ case LogLevel.Trace:
+ case LogLevel.Debug:
+ case LogLevel.None:
+ return "success";
+ case LogLevel.Information:
+ return "info";
+ case LogLevel.Warning:
+ return "warn";
+ case LogLevel.Error:
+ return "danger";
+ case LogLevel.Critical:
+ return "danger-strong";
+ }
+
+ return string.Empty;
+ },
+ "ToString")
+ .BuildAndParseAsync()
+ .ConfigureAwait(false))
+ .CreateCompiledRenderer();
+
ThrowIfDisposed();
- _startupServer = Host.CreateDefaultBuilder()
+ var retryAfterValue = TimeSpan.FromSeconds(5);
+ var config = _configurationManager.GetNetworkConfiguration()!;
+ _startupServer = Host.CreateDefaultBuilder(["hostBuilder:reloadConfigOnChange=false"])
.UseConsoleLifetime()
+ .UseSerilog()
.ConfigureServices(serv =>
{
+ serv.AddSingleton(this);
serv.AddHealthChecks()
.AddCheck<SetupHealthcheck>("StartupCheck");
+ serv.Configure<ForwardedHeadersOptions>(options =>
+ {
+ ApiServiceCollectionExtensions.ConfigureForwardHeaders(config, options);
+ });
})
.ConfigureWebHostDefaults(webHostBuilder =>
{
webHostBuilder
.UseKestrel((builderContext, options) =>
{
- var config = _configurationManager.GetNetworkConfiguration()!;
var knownBindInterfaces = NetworkManager.GetInterfacesCore(_loggerFactory.CreateLogger<SetupServer>(), config.EnableIPv4, config.EnableIPv6);
knownBindInterfaces = NetworkManager.FilterBindSettings(config, knownBindInterfaces.ToList(), config.EnableIPv4, config.EnableIPv6);
var bindInterfaces = NetworkManager.GetAllBindInterfaces(false, _configurationManager, knownBindInterfaces, config.EnableIPv4, config.EnableIPv6);
@@ -99,7 +177,7 @@ public sealed class SetupServer : IDisposable
.Configure(app =>
{
app.UseHealthChecks("/health");
-
+ app.UseForwardedHeaders();
app.Map("/startup/logger", loggerRoute =>
{
loggerRoute.Run(async context =>
@@ -113,7 +191,7 @@ public sealed class SetupServer : IDisposable
var logFilePath = new DirectoryInfo(_applicationPaths.LogDirectoryPath)
.EnumerateFiles()
- .OrderBy(f => f.CreationTimeUtc)
+ .OrderByDescending(f => f.CreationTimeUtc)
.FirstOrDefault()
?.FullName;
if (logFilePath is not null)
@@ -140,7 +218,7 @@ public sealed class SetupServer : IDisposable
if (jfApplicationHost is null)
{
context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
- context.Response.Headers.RetryAfter = new StringValues("5");
+ context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture));
return;
}
@@ -158,24 +236,32 @@ public sealed class SetupServer : IDisposable
});
});
- app.Run((context) =>
+ app.Run(async (context) =>
{
context.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
- context.Response.Headers.RetryAfter = new StringValues("5");
+ context.Response.Headers.RetryAfter = new StringValues(retryAfterValue.TotalSeconds.ToString("000", CultureInfo.InvariantCulture));
context.Response.Headers.ContentType = new StringValues("text/html");
- context.Response.WriteAsync("<p>Jellyfin Server still starting. Please wait.</p>");
var networkManager = _networkManagerFactory();
- if (networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress))
- {
- context.Response.WriteAsync("<p>You can download the current logfiles <a href='/startup/logger'>here</a>.</p>");
- }
- return Task.CompletedTask;
+ var startupLogEntries = LogQueue?.ToArray() ?? [];
+ await _startupUiRenderer.RenderAsync(
+ new Dictionary<string, object>()
+ {
+ { "isInReportingMode", _isUnhealthy },
+ { "retryValue", retryAfterValue },
+ { "version", typeof(Emby.Server.Implementations.ApplicationHost).Assembly.GetName().Version! },
+ { "logs", startupLogEntries },
+ { "networkManagerReady", networkManager is not null },
+ { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
+ },
+ new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
+ .ConfigureAwait(false);
});
});
})
.Build();
await _startupServer.StartAsync().ConfigureAwait(false);
+ IsAlive = true;
}
/// <summary>
@@ -191,6 +277,7 @@ public sealed class SetupServer : IDisposable
}
await _startupServer.StopAsync().ConfigureAwait(false);
+ IsAlive = false;
}
/// <inheritdoc/>
@@ -203,6 +290,9 @@ public sealed class SetupServer : IDisposable
_disposed = true;
_startupServer?.Dispose();
+ IsAlive = false;
+ LogQueue?.Clear();
+ LogQueue = null;
}
private void ThrowIfDisposed()
@@ -210,11 +300,77 @@ public sealed class SetupServer : IDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
}
+ internal void SoftStop()
+ {
+ _isUnhealthy = true;
+ }
+
private class SetupHealthcheck : IHealthCheck
{
+ private readonly SetupServer _startupServer;
+
+ public SetupHealthcheck(SetupServer startupServer)
+ {
+ _startupServer = startupServer;
+ }
+
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
+ if (_startupServer._isUnhealthy)
+ {
+ return Task.FromResult(HealthCheckResult.Unhealthy("Server is could not complete startup. Check logs."));
+ }
+
return Task.FromResult(HealthCheckResult.Degraded("Server is still starting up."));
}
}
+
+ internal sealed class SetupLoggerFactory : ILoggerProvider, IDisposable
+ {
+ private bool _disposed;
+
+ public ILogger CreateLogger(string categoryName)
+ {
+ return new CatchingSetupServerLogger();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ }
+ }
+
+ internal sealed class CatchingSetupServerLogger : ILogger
+ {
+ public IDisposable? BeginScope<TState>(TState state)
+ where TState : notnull
+ {
+ return null;
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return logLevel is LogLevel.Error or LogLevel.Critical;
+ }
+
+ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+ {
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ LogQueue?.Enqueue(new()
+ {
+ LogLevel = logLevel,
+ Content = formatter(state, exception),
+ DateOfCreation = DateTimeOffset.Now
+ });
+ }
+ }
}
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs
new file mode 100644
index 000000000..cd440a9b5
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupLogTopic.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.ObjectModel;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <summary>
+/// Defines a topic for the Startup UI.
+/// </summary>
+public class StartupLogTopic
+{
+ /// <summary>
+ /// Gets or Sets the LogLevel.
+ /// </summary>
+ public LogLevel LogLevel { get; set; }
+
+ /// <summary>
+ /// Gets or Sets the descriptor for the topic.
+ /// </summary>
+ public string? Content { get; set; }
+
+ /// <summary>
+ /// Gets or sets the time the topic was created.
+ /// </summary>
+ public DateTimeOffset DateOfCreation { get; set; }
+
+ /// <summary>
+ /// Gets the child items of this topic.
+ /// </summary>
+ public Collection<StartupLogTopic> Children { get; } = [];
+}
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLogger.cs b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
new file mode 100644
index 000000000..0121854ce
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupLogger.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <inheritdoc/>
+public class StartupLogger : IStartupLogger
+{
+ private readonly StartupLogTopic? _topic;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger"/> class.
+ /// </summary>
+ /// <param name="logger">The underlying base logger.</param>
+ public StartupLogger(ILogger logger)
+ {
+ BaseLogger = logger;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger"/> class.
+ /// </summary>
+ /// <param name="logger">The underlying base logger.</param>
+ /// <param name="topic">The group for this logger.</param>
+ internal StartupLogger(ILogger logger, StartupLogTopic? topic) : this(logger)
+ {
+ _topic = topic;
+ }
+
+ internal static IStartupLogger Logger { get; set; } = new StartupLogger(NullLogger.Instance);
+
+ /// <inheritdoc/>
+ public StartupLogTopic? Topic => _topic;
+
+ /// <summary>
+ /// Gets or Sets the underlying base logger.
+ /// </summary>
+ protected ILogger BaseLogger { get; set; }
+
+ /// <inheritdoc/>
+ public IStartupLogger BeginGroup(FormattableString logEntry)
+ {
+ return new StartupLogger(BaseLogger, AddToTopic(logEntry));
+ }
+
+ /// <inheritdoc/>
+ public IStartupLogger With(ILogger logger)
+ {
+ return new StartupLogger(logger, Topic);
+ }
+
+ /// <inheritdoc/>
+ public IStartupLogger<TCategory> With<TCategory>(ILogger logger)
+ {
+ return new StartupLogger<TCategory>(logger, Topic);
+ }
+
+ /// <inheritdoc/>
+ public IStartupLogger<TCategory> BeginGroup<TCategory>(FormattableString logEntry)
+ {
+ return new StartupLogger<TCategory>(BaseLogger, AddToTopic(logEntry));
+ }
+
+ private StartupLogTopic AddToTopic(FormattableString logEntry)
+ {
+ var startupEntry = new StartupLogTopic()
+ {
+ Content = logEntry.ToString(CultureInfo.InvariantCulture),
+ DateOfCreation = DateTimeOffset.Now
+ };
+
+ if (Topic is null)
+ {
+ SetupServer.LogQueue?.Enqueue(startupEntry);
+ }
+ else
+ {
+ Topic.Children.Add(startupEntry);
+ }
+
+ return startupEntry;
+ }
+
+ /// <inheritdoc/>
+ public IDisposable? BeginScope<TState>(TState state)
+ where TState : notnull
+ {
+ return null;
+ }
+
+ /// <inheritdoc/>
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return true;
+ }
+
+ /// <inheritdoc/>
+ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+ {
+ if (BaseLogger.IsEnabled(logLevel))
+ {
+ // if enabled allow the base logger also to receive the message
+ BaseLogger.Log(logLevel, eventId, state, exception, formatter);
+ }
+
+ var startupEntry = new StartupLogTopic()
+ {
+ LogLevel = logLevel,
+ Content = formatter(state, exception),
+ DateOfCreation = DateTimeOffset.Now
+ };
+
+ if (Topic is null)
+ {
+ SetupServer.LogQueue?.Enqueue(startupEntry);
+ }
+ else
+ {
+ Topic.Children.Add(startupEntry);
+ }
+ }
+}
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs
new file mode 100644
index 000000000..ada4b56a7
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerExtensions.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Globalization;
+using System.Linq;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+internal static class StartupLoggerExtensions
+{
+ public static IServiceCollection RegisterStartupLogger(this IServiceCollection services)
+ {
+ return services
+ .AddTransient<IStartupLogger, StartupLogger<Startup>>()
+ .AddTransient(typeof(IStartupLogger<>), typeof(StartupLogger<>));
+ }
+}
diff --git a/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs
new file mode 100644
index 000000000..64da0ce88
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/StartupLoggerOfCategory.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.ServerSetupApp;
+
+/// <summary>
+/// Startup logger for usage with DI that utilises an underlying logger from the DI.
+/// </summary>
+/// <typeparam name="TCategory">The category of the underlying logger.</typeparam>
+#pragma warning disable SA1649 // File name should match first type name
+public class StartupLogger<TCategory> : StartupLogger, IStartupLogger<TCategory>
+#pragma warning restore SA1649 // File name should match first type name
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class.
+ /// </summary>
+ /// <param name="logger">The injected base logger.</param>
+ public StartupLogger(ILogger<TCategory> logger) : base(logger)
+ {
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StartupLogger{TCategory}"/> class.
+ /// </summary>
+ /// <param name="logger">The underlying base logger.</param>
+ /// <param name="groupEntry">The group for this logger.</param>
+ internal StartupLogger(ILogger logger, StartupLogTopic? groupEntry) : base(logger, groupEntry)
+ {
+ }
+
+ IStartupLogger<TCategory> IStartupLogger<TCategory>.BeginGroup(FormattableString logEntry)
+ {
+ var startupEntry = new StartupLogTopic()
+ {
+ Content = logEntry.ToString(CultureInfo.InvariantCulture),
+ DateOfCreation = DateTimeOffset.Now
+ };
+
+ if (Topic is null)
+ {
+ SetupServer.LogQueue?.Enqueue(startupEntry);
+ }
+ else
+ {
+ Topic.Children.Add(startupEntry);
+ }
+
+ return new StartupLogger<TCategory>(BaseLogger, startupEntry);
+ }
+
+ IStartupLogger<TCategory> IStartupLogger<TCategory>.With(ILogger logger)
+ {
+ return new StartupLogger<TCategory>(logger, Topic);
+ }
+}
diff --git a/Jellyfin.Server/ServerSetupApp/index.mstemplate.html b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
new file mode 100644
index 000000000..890a77619
--- /dev/null
+++ b/Jellyfin.Server/ServerSetupApp/index.mstemplate.html
@@ -0,0 +1,235 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="UTF-8" />
+ <title>
+ {{#IF isInReportingMode}}
+ ❌
+ {{/IF}}
+ Jellyfin Startup
+ </title>
+ <style>
+ * {
+ font-family: sans-serif;
+ }
+
+ .flex-row {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ justify-content: center;
+ align-items: center;
+ align-content: normal;
+ }
+
+ .flex-col {
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ justify-content: center;
+ align-items: center;
+ align-content: normal;
+ }
+
+ header {
+ height: 5rem;
+ width: 100%;
+ }
+
+ header svg {
+ height: 3rem;
+ width: 9rem;
+ margin-right: 1rem;
+ }
+
+ /* ol.action-list {
+ list-style-type: none;
+ position: relative;
+ } */
+
+ ol.action-list * {
+ font-family: monospace;
+ font-weight: 300;
+ font-size: clamp(18px, 100vw / var(--width), 20px);
+ font-feature-settings: 'onum', 'pnum';
+ line-height: 1.8;
+ -webkit-text-size-adjust: none;
+ }
+
+ /*
+ ol.action-list li {
+ padding-top: .5rem;
+ }
+
+ ol.action-list li::before {
+ position: absolute;
+ left: -0.8em;
+ font-size: 1.1em;
+ } */
+
+ /* Attribution as heavily inspired by: https://iamkate.com/code/tree-views/ */
+ .action-list {
+ --spacing: 1.4rem;
+ --radius: 14px;
+ }
+
+ .action-list li {
+ display: block;
+ position: relative;
+ padding-left: calc(2 * var(--spacing) - var(--radius) - 1px);
+ }
+
+ .action-list ul {
+ margin-left: calc(var(--radius) - var(--spacing));
+ padding-left: 0;
+ }
+
+ .action-list ul li {
+ border-left: 2px solid #ddd;
+ }
+
+ .action-list ul li:last-child {
+ border-color: transparent;
+ }
+
+ .action-list ul li::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: calc(var(--spacing) / -2);
+ left: -2px;
+ width: calc(var(--spacing) + 2px);
+ height: calc(var(--spacing) + 1px);
+ border: solid #ddd;
+ border-width: 0 0 2px 2px;
+ }
+
+ .action-list summary {
+ display: block;
+ cursor: pointer;
+ }
+
+ .action-list summary::marker,
+ .action-list summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .action-list summary:focus {
+ outline: none;
+ }
+
+ .action-list summary:focus-visible {
+ outline: 1px dotted #000;
+ }
+
+ .action-list li::after,
+ .action-list summary::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: calc(var(--spacing) / 2 - var(--radius) + 4px);
+ left: calc(var(--spacing) - var(--radius) - -5px);
+ }
+
+ .action-list summary::before {
+ z-index: 1;
+ /* background: #696 url('expand-collapse.svg') 0 0; */
+ }
+
+ .action-list details[open]>summary::before {
+ background-position: calc(-2 * var(--radius)) 0;
+ }
+
+ .action-list li.danger-item::after,
+ .action-list li.danger-strong-item::after {
+ content: '❌';
+ }
+
+ ol.action-list li span.danger-strong-item {
+ text-decoration-style: solid;
+ text-decoration-color: red;
+ text-decoration-line: underline;
+ }
+
+ ol.action-list li.warn-item::after {
+ content: '⚠️';
+ }
+
+ ol.action-list li.success-item::after {
+ content: '✅';
+ }
+
+ ol.action-list li.info-item::after {
+ content: '🔹';
+ }
+
+ /* End Attribution */
+ </style>
+</head>
+
+<body>
+ <div>
+ <header class="flex-row">
+
+ {{^IF isInReportingMode}}
+ <p>Jellyfin Server {{version}} still starting. Please wait.</p>
+ {{#ELSE}}
+ <p>Jellyfin Server has encountered an error and was not able to start.</p>
+ {{/ELSE}}
+ {{/IF}}
+
+ {{#IF localNetworkRequest}}
+ <p style="margin-left: 1rem;">You can download the current log file <a href='/startup/logger'
+ target="_blank">here</a>.</p>
+ {{/IF}}
+ </header>
+
+ {{#DECLARE LogEntry |--}}
+ {{#LET children = Children}}
+ <li class="{{FormatLogLevel(children).ToString()}}-item">
+ {{--| #IF children.Count > 0}}
+ <details open>
+ <summary>{{DateOfCreation}} - {{Content}}</summary>
+ <ul class="action-list">
+ {{--| #EACH children.Reverse() |-}}
+ {{#IMPORT 'LogEntry'}}
+ {{--| /EACH |-}}
+ </ul>
+ </details>
+ {{--| #ELSE |-}}
+ <span class="{{FormatLogLevel(children).ToString()}}-item">{{DateOfCreation}} - {{Content}}</span>
+ {{--| /ELSE |--}}
+ {{--| /IF |-}}
+ </li>
+ {{--| /DECLARE}}
+
+ {{#IF localNetworkRequest}}
+ <div class="flex-col">
+ <ol class="action-list">
+ {{#FOREACH log IN logs.Reverse()}}
+ {{#IMPORT 'LogEntry' #WITH log}}
+ {{/FOREACH}}
+ </ol>
+ </div>
+ {{#ELSE}}
+ {{#IF networkManagerReady}}
+ <p>Please visit this page from your local network to view detailed startup logs.</p>
+ {{#ELSE}}
+ <p>Initializing network settings. Please wait.</p>
+ {{/ELSE}}
+ {{/IF}}
+ {{/ELSE}}
+ {{/IF}}
+ </div>
+</body>
+
+{{^IF isInReportingMode}}
+<script>
+ setTimeout(() => {
+ window.location.reload();
+ }, {{ retryValue.TotalMilliseconds }});
+</script>
+{{/IF}}
+
+</html>
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 688b16935..5032b2aec 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -1,4 +1,5 @@
using System;
+using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
@@ -15,20 +16,18 @@ using Jellyfin.Networking.HappyEyeballs;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.HealthChecks;
using Jellyfin.Server.Implementations.Extensions;
-using Jellyfin.Server.Infrastructure;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Extensions;
using MediaBrowser.XbmcMetadata;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Primitives;
using Prometheus;
namespace Jellyfin.Server
@@ -67,8 +66,6 @@ namespace Jellyfin.Server
options.HttpsPort = _serverApplicationHost.HttpsPort;
});
- // TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371
- services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>();
services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration());
services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration);
services.AddJellyfinApiSwagger();
@@ -195,7 +192,14 @@ namespace Jellyfin.Server
{
FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath),
RequestPath = "/web",
- ContentTypeProvider = extensionProvider
+ ContentTypeProvider = extensionProvider,
+ OnPrepareResponse = (context) =>
+ {
+ if (Path.GetFileName(context.File.Name).Equals("index.html", StringComparison.Ordinal))
+ {
+ context.Context.Response.Headers.CacheControl = new StringValues("no-cache");
+ }
+ }
});
mainApp.UseRobotsRedirection();
@@ -208,7 +212,6 @@ namespace Jellyfin.Server
mainApp.UseRouting();
mainApp.UseAuthorization();
- mainApp.UseLanFiltering();
mainApp.UseIPBasedAccessValidation();
mainApp.UseWebSocketHandler();
mainApp.UseServerStartupMessage();
diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs
index 91ac827ca..4890ccbb2 100644
--- a/Jellyfin.Server/StartupOptions.cs
+++ b/Jellyfin.Server/StartupOptions.cs
@@ -74,6 +74,12 @@ namespace Jellyfin.Server
public bool NoDetectNetworkChange { get; set; }
/// <summary>
+ /// Gets or sets the path to an jellyfin backup archive to restore the application to.
+ /// </summary>
+ [Option("restore-archive", Required = false, HelpText = "Path to a Jellyfin backup archive to restore from")]
+ public string? RestoreArchive { get; set; }
+
+ /// <summary>
/// Gets the command line options as a dictionary that can be used in the .NET configuration system.
/// </summary>
/// <returns>The configuration dictionary.</returns>
diff --git a/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg b/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg
deleted file mode 100644
index b62b7545c..000000000
--- a/Jellyfin.Server/wwwroot/api-docs/banner-dark.svg
+++ /dev/null
@@ -1,34 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!-- ***** BEGIN LICENSE BLOCK *****
- - Part of the Jellyfin project (https://jellyfin.media)
- -
- - All copyright belongs to the Jellyfin contributors; a full list can
- - be found in the file CONTRIBUTORS.md
- -
- - This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- - To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
-- ***** END LICENSE BLOCK ***** -->
-<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
- <defs>
- <linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
- <stop offset="0" stop-color="#aa5cc3"/>
- <stop offset="1" stop-color="#00a4dc"/>
- </linearGradient>
- </defs>
- <title>banner-dark</title>
- <g id="banner-dark">
- <g id="banner-dark-icon">
- <path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
- <path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
- </g>
- <g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)">
- <path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/>
- <path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/>
- <path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/>
- <path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/>
- <path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/>
- <path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/>
- <path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/>
- </g>
- </g>
-</svg> \ No newline at end of file
diff --git a/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg b/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg
new file mode 100644
index 000000000..692530319
--- /dev/null
+++ b/Jellyfin.Server/wwwroot/api-docs/jellyfin.svg
@@ -0,0 +1,26 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="251" height="72" fill="none" viewBox="0 0 251 72">
+ <g clip-path="url(#a)">
+ <path fill="url(#b)"
+ d="M24.212 49.158C22.66 46.042 32.838 27.588 36 27.588c3.167.002 13.323 18.488 11.788 21.57-1.534 3.082-22.025 3.116-23.576 0" />
+ <path fill="url(#c)" fill-rule="evenodd"
+ d="M.482 64.995C-4.195 55.605 26.477 0 36 0c9.533 0 40.153 55.713 35.527 64.995s-66.368 9.39-71.045 0m12.254-8.148c3.064 6.152 43.518 6.084 46.548 0 3.03-6.086-17.032-42.586-23.275-42.586S9.671 50.694 12.736 56.847"
+ clip-rule="evenodd" />
+ <path fill="#fff"
+ d="M225.22 56c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.219-.218c-.054-.107-.054-.247-.054-.527V26.8c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.183c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v2.895a7.9 7.9 0 0 1 3.419-3.254q2.261-1.103 5.074-1.103 3.308 0 5.845 1.434a10.1 10.1 0 0 1 4.026 4.026q1.434 2.536 1.434 5.9V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.218c-.107.055-.247.055-.527.055h-5.625c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527V38.408q0-2.978-1.709-4.688-1.654-1.764-4.357-1.764-2.702 0-4.412 1.764-1.654 1.766-1.654 4.688V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm-11.54-33.363c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527v-6.121c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527v6.12c0 .28 0 .42-.054.528a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm0 33.363c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.219c-.055-.107-.055-.247-.055-.527V26.8c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h5.624c.28 0 .42 0 .527.055a.5.5 0 0 1 .219.218c.054.107.054.247.054.527v28.4c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-16.712-.054c.107.054.247.054.527.054h5.625c.28 0 .42 0 .526-.054a.5.5 0 0 0 .219-.219c.055-.107.055-.247.055-.527V32.452h5.872c.28 0 .42 0 .527-.054a.5.5 0 0 0 .219-.219c.054-.107.054-.247.054-.527V26.8c0-.28 0-.42-.054-.527a.5.5 0 0 0-.219-.218c-.107-.055-.247-.055-.527-.055h-5.872v-.992q0-2.261 1.323-3.31 1.379-1.102 3.75-1.102.454 0 .939.044c.345.031.518.047.634-.004a.48.48 0 0 0 .241-.22c.061-.111.061-.274.061-.6V15.39c0-.304 0-.457-.061-.589a.7.7 0 0 0-.248-.284c-.122-.078-.261-.097-.537-.136a14.5 14.5 0 0 0-1.966-.126q-5.184 0-8.273 2.812t-3.088 7.942V26H186.53c-.3 0-.451 0-.58.05a.75.75 0 0 0-.296.205c-.091.104-.143.244-.248.526l-7.43 19.9-7.483-19.903c-.105-.28-.158-.42-.249-.524a.75.75 0 0 0-.296-.205c-.129-.049-.279-.049-.578-.049h-5.769c-.394 0-.591 0-.717.083a.5.5 0 0 0-.213.314c-.031.147.041.33.186.697L174.281 56l-.661 1.6q-.883 1.874-2.041 3.033-1.103 1.158-3.584 1.158-.883 0-1.875-.166a13 13 0 0 1-.73-.1c-.389-.066-.584-.099-.709-.053a.47.47 0 0 0-.26.22c-.066.116-.066.298-.066.663v4.329c0 .243 0 .365.045.481a.7.7 0 0 0 .189.266c.095.081.194.116.392.185q.684.24 1.47.351 1.158.22 2.371.22 4.246 0 7.059-2.426 2.867-2.37 4.577-6.728l10.517-26.58h5.72V55.2c0 .28 0 .42.055.527a.5.5 0 0 0 .218.219M154.363 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.219c-.107.054-.247.054-.527.054zm-11.621 0c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-18.132.662q-4.632-.001-8.107-2.096a14.6 14.6 0 0 1-5.404-5.68q-1.93-3.585-1.93-7.942 0-4.522 1.93-7.996 1.985-3.53 5.349-5.57 3.42-2.04 7.61-2.04 4.688 0 7.942 2.04 3.253 1.986 4.963 5.294 1.71 3.309 1.709 7.335 0 .828-.11 1.654-.031.45-.12.841c-.037.165-.055.247-.115.33a.55.55 0 0 1-.208.168c-.095.04-.194.04-.393.04h-21.057q.33 3.309 2.537 5.294 2.205 1.986 5.459 1.985 2.482 0 4.191-1.047a8.2 8.2 0 0 0 2.206-1.986c.241-.316.362-.474.484-.542a.6.6 0 0 1 .352-.083c.139.006.296.083.608.236l4.269 2.094c.239.118.359.176.431.275a.52.52 0 0 1 .098.298c0 .122-.058.231-.172.45q-1.432 2.742-4.526 4.607-3.419 2.04-7.996 2.04m-.552-25.368q-2.702 0-4.687 1.654-1.93 1.6-2.537 4.577h14.118q-.22-2.757-2.151-4.466-1.875-1.765-4.743-1.765M90.801 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.218C90 55.62 90 55.48 90 55.2v-5.294c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h1.572q2.646 0 4.19-1.489 1.6-1.545 1.6-4.08V15.715c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.956c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v27.546q0 3.804-1.655 6.672-1.599 2.868-4.632 4.467-2.979 1.6-7.06 1.6z" />
+ </g>
+ <defs>
+ <linearGradient id="b" x1="12" x2="71.999" y1="30.001" y2="63.002"
+ gradientUnits="userSpaceOnUse">
+ <stop stop-color="#aa5cc3" />
+ <stop offset="1" stop-color="#00a4dc" />
+ </linearGradient>
+ <linearGradient id="c" x1="12" x2="71.999" y1="29.999" y2="63.001"
+ gradientUnits="userSpaceOnUse">
+ <stop stop-color="#aa5cc3" />
+ <stop offset="1" stop-color="#00a4dc" />
+ </linearGradient>
+ <clipPath id="a">
+ <path fill="#fff" d="M0 0h251v72H0z" />
+ </clipPath>
+ </defs>
+</svg> \ No newline at end of file
diff --git a/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
index acb59888e..c14ad6021 100644
--- a/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
+++ b/Jellyfin.Server/wwwroot/api-docs/swagger/custom.css
@@ -4,12 +4,14 @@
}
.topbar-wrapper .link:after {
- content: url(../banner-dark.svg);
+ content: '';
display: block;
- -moz-box-sizing: border-box;
+ background-image: url(../jellyfin.svg);
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: contain;
box-sizing: border-box;
- max-width: 100%;
- max-height: 100%;
- width: 150px;
+ width: 220px;
+ height: 40px;
}
/* end logo */
diff --git a/Jellyfin.sln b/Jellyfin.sln
index cdc8c8f65..fb1f2a2c2 100644
--- a/Jellyfin.sln
+++ b/Jellyfin.sln
@@ -88,11 +88,16 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.LiveTv", "src\Jellyfin.LiveTv\Jellyfin.LiveTv.csproj", "{8C6B2B13-58A4-4506-9DAB-1F882A093FE0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyfin.Database", "{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}"
+ ProjectSection(SolutionItems) = preProject
+ src\Jellyfin.Database\readme.md = src\Jellyfin.Database\readme.md
+ EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -255,6 +260,10 @@ Global
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.Build.0 = Release|Any CPU
+ {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -286,6 +295,7 @@ Global
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
+ {11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
diff --git a/MediaBrowser.Common/Configuration/EncodingConfigurationExtensions.cs b/MediaBrowser.Common/Configuration/EncodingConfigurationExtensions.cs
index 70a4fe409..78e96ab47 100644
--- a/MediaBrowser.Common/Configuration/EncodingConfigurationExtensions.cs
+++ b/MediaBrowser.Common/Configuration/EncodingConfigurationExtensions.cs
@@ -35,8 +35,7 @@ namespace MediaBrowser.Common.Configuration
transcodingTempPath = Path.Combine(configurationManager.CommonApplicationPaths.CachePath, "transcodes");
}
- // Make sure the directory exists
- Directory.CreateDirectory(transcodingTempPath);
+ configurationManager.CommonApplicationPaths.CreateAndCheckMarker(transcodingTempPath, "transcode", true);
return transcodingTempPath;
}
}
diff --git a/MediaBrowser.Common/Configuration/IApplicationPaths.cs b/MediaBrowser.Common/Configuration/IApplicationPaths.cs
index 7a8ab3236..3a6197490 100644
--- a/MediaBrowser.Common/Configuration/IApplicationPaths.cs
+++ b/MediaBrowser.Common/Configuration/IApplicationPaths.cs
@@ -90,5 +90,24 @@ namespace MediaBrowser.Common.Configuration
/// </summary>
/// <value>The trickplay path.</value>
string TrickplayPath { get; }
+
+ /// <summary>
+ /// Gets the path used for storing backup archives.
+ /// </summary>
+ /// <value>The backup path.</value>
+ string BackupPath { get; }
+
+ /// <summary>
+ /// Checks and creates all known base paths.
+ /// </summary>
+ void MakeSanityCheckOrThrow();
+
+ /// <summary>
+ /// Checks and creates the given path and adds it with a marker file if non existent.
+ /// </summary>
+ /// <param name="path">The path to check.</param>
+ /// <param name="markerName">The common marker file name.</param>
+ /// <param name="recursive">Check for other settings paths recursively.</param>
+ void CreateAndCheckMarker(string path, string markerName, bool recursive = false);
}
}
diff --git a/MediaBrowser.Common/Extensions/HttpContextExtensions.cs b/MediaBrowser.Common/Extensions/HttpContextExtensions.cs
index a1056b7c8..739a53c7a 100644
--- a/MediaBrowser.Common/Extensions/HttpContextExtensions.cs
+++ b/MediaBrowser.Common/Extensions/HttpContextExtensions.cs
@@ -12,7 +12,7 @@ namespace MediaBrowser.Common.Extensions
/// Checks the origin of the HTTP context.
/// </summary>
/// <param name="context">The incoming HTTP context.</param>
- /// <returns><c>true</c> if the request is coming from LAN, <c>false</c> otherwise.</returns>
+ /// <returns><c>true</c> if the request is coming from the same machine as is running the server, <c>false</c> otherwise.</returns>
public static bool IsLocal(this HttpContext context)
{
return (context.Connection.LocalIpAddress is null
diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj
index de6be4707..9af13b0a7 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>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Common/Net/INetworkManager.cs b/MediaBrowser.Common/Net/INetworkManager.cs
index d838144ff..bd785bcbc 100644
--- a/MediaBrowser.Common/Net/INetworkManager.cs
+++ b/MediaBrowser.Common/Net/INetworkManager.cs
@@ -127,7 +127,7 @@ namespace MediaBrowser.Common.Net
/// Checks if <paramref name="remoteIP"/> has access to the server.
/// </summary>
/// <param name="remoteIP">IP address of the client.</param>
- /// <returns><b>True</b> if it has access, otherwise <b>false</b>.</returns>
- bool HasRemoteAccess(IPAddress remoteIP);
+ /// <returns>The result of evaluating the access policy, <c>Allow</c> if it should be allowed.</returns>
+ RemoteAccessPolicyResult ShouldAllowServerAccess(IPAddress remoteIP);
}
}
diff --git a/MediaBrowser.Common/Net/RemoteAccessPolicyResult.cs b/MediaBrowser.Common/Net/RemoteAccessPolicyResult.cs
new file mode 100644
index 000000000..193d37228
--- /dev/null
+++ b/MediaBrowser.Common/Net/RemoteAccessPolicyResult.cs
@@ -0,0 +1,29 @@
+using System;
+
+namespace MediaBrowser.Common.Net;
+
+/// <summary>
+/// Result of <see cref="INetworkManager.ShouldAllowServerAccess" />.
+/// </summary>
+public enum RemoteAccessPolicyResult
+{
+ /// <summary>
+ /// The connection should be allowed.
+ /// </summary>
+ Allow,
+
+ /// <summary>
+ /// The connection should be rejected since it is not from a local IP and remote access is disabled.
+ /// </summary>
+ RejectDueToRemoteAccessDisabled,
+
+ /// <summary>
+ /// The connection should be rejected since it is from a blocklisted IP.
+ /// </summary>
+ RejectDueToIPBlocklist,
+
+ /// <summary>
+ /// The connection should be rejected since it is from a remote IP that is not in the allowlist.
+ /// </summary>
+ RejectDueToNotAllowlistedRemoteIP,
+}
diff --git a/MediaBrowser.Common/Plugins/BasePluginOfT.cs b/MediaBrowser.Common/Plugins/BasePluginOfT.cs
index 58992ecd7..30c67fa05 100644
--- a/MediaBrowser.Common/Plugins/BasePluginOfT.cs
+++ b/MediaBrowser.Common/Plugins/BasePluginOfT.cs
@@ -145,10 +145,7 @@ namespace MediaBrowser.Common.Plugins
lock (_configurationSaveLock)
{
var folder = Path.GetDirectoryName(ConfigurationFilePath);
- if (!Directory.Exists(folder))
- {
- Directory.CreateDirectory(folder);
- }
+ Directory.CreateDirectory(folder);
XmlSerializer.SerializeToFile(config, ConfigurationFilePath);
}
diff --git a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs
index 976a667ac..c993ceea8 100644
--- a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs
+++ b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs
@@ -14,8 +14,6 @@ namespace MediaBrowser.Controller.Authentication
Task<ProviderAuthenticationResult> Authenticate(string username, string password);
- bool HasPassword(User user);
-
Task ChangePassword(User user, string newPassword);
}
diff --git a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs
index 592ce9955..36cd5c5d1 100644
--- a/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs
+++ b/MediaBrowser.Controller/Authentication/IPasswordResetProvider.cs
@@ -1,5 +1,3 @@
-#nullable disable
-
#pragma warning disable CS1591
using System;
@@ -15,11 +13,12 @@ namespace MediaBrowser.Controller.Authentication
bool IsEnabled { get; }
- Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork);
+ Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork);
Task<PinRedeemResult> RedeemPasswordResetPin(string pin);
}
+#nullable disable
public class PasswordPinCreationResult
{
public string PinFile { get; set; }
diff --git a/MediaBrowser.Controller/Chapters/IChapterManager.cs b/MediaBrowser.Controller/Chapters/IChapterManager.cs
new file mode 100644
index 000000000..25656fd62
--- /dev/null
+++ b/MediaBrowser.Controller/Chapters/IChapterManager.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Controller.Chapters;
+
+/// <summary>
+/// Interface IChapterManager.
+/// </summary>
+public interface IChapterManager
+{
+ /// <summary>
+ /// Saves the chapters.
+ /// </summary>
+ /// <param name="video">The video.</param>
+ /// <param name="chapters">The set of chapters.</param>
+ void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters);
+
+ /// <summary>
+ /// Gets a single chapter of a BaseItem on a specific index.
+ /// </summary>
+ /// <param name="baseItemId">The BaseItems id.</param>
+ /// <param name="index">The index of that chapter.</param>
+ /// <returns>A chapter instance.</returns>
+ ChapterInfo? GetChapter(Guid baseItemId, int index);
+
+ /// <summary>
+ /// Gets all chapters associated with the baseItem.
+ /// </summary>
+ /// <param name="baseItemId">The BaseItems id.</param>
+ /// <returns>A readonly list of chapter instances.</returns>
+ IReadOnlyList<ChapterInfo> GetChapters(Guid baseItemId);
+
+ /// <summary>
+ /// Refreshes the chapter images.
+ /// </summary>
+ /// <param name="video">Video to use.</param>
+ /// <param name="directoryService">Directory service to use.</param>
+ /// <param name="chapters">Set of chapters to refresh.</param>
+ /// <param name="extractImages">Option to extract images.</param>
+ /// <param name="saveChapters">Option to save chapters.</param>
+ /// <param name="cancellationToken">CancellationToken to use for operation.</param>
+ /// <returns><c>true</c> if successful, <c>false</c> if not.</returns>
+ Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Deletes the chapter data.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken);
+}
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index d1a6b3584..3c46d53e5 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -24,7 +24,9 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
@@ -34,7 +36,6 @@ using MediaBrowser.Model.IO;
using MediaBrowser.Model.Library;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
-using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Controller.Entities
@@ -107,8 +108,15 @@ namespace MediaBrowser.Controller.Entities
ProductionLocations = Array.Empty<string>();
RemoteTrailers = Array.Empty<MediaUrl>();
ExtraIds = Array.Empty<Guid>();
+ UserData = [];
}
+ /// <summary>
+ /// Gets or Sets the user data collection as cached from the last Db query.
+ /// </summary>
+ [JsonIgnore]
+ public ICollection<UserData> UserData { get; set; }
+
[JsonIgnore]
public string PreferredMetadataCountryCode { get; set; }
@@ -484,7 +492,7 @@ namespace MediaBrowser.Controller.Entities
public static IItemRepository ItemRepository { get; set; }
- public static IChapterRepository ChapterRepository { get; set; }
+ public static IChapterManager ChapterManager { get; set; }
public static IFileSystem FileSystem { get; set; }
@@ -701,19 +709,7 @@ namespace MediaBrowser.Controller.Entities
{
get
{
- var customRating = CustomRating;
- if (!string.IsNullOrEmpty(customRating))
- {
- return customRating;
- }
-
- var parent = DisplayParent;
- if (parent is not null)
- {
- return parent.CustomRatingForComparison;
- }
-
- return null;
+ return GetCustomRatingForComparision();
}
}
@@ -791,6 +787,26 @@ namespace MediaBrowser.Controller.Entities
/// <value>The remote trailers.</value>
public IReadOnlyList<MediaUrl> RemoteTrailers { get; set; }
+ private string GetCustomRatingForComparision(HashSet<Guid> callstack = null)
+ {
+ callstack ??= new();
+ var customRating = CustomRating;
+ if (!string.IsNullOrEmpty(customRating))
+ {
+ return customRating;
+ }
+
+ callstack.Add(Id);
+
+ var parent = DisplayParent;
+ if (parent is not null && !callstack.Contains(parent.Id))
+ {
+ return parent.GetCustomRatingForComparision(callstack);
+ }
+
+ return null;
+ }
+
public virtual double GetDefaultPrimaryImageAspectRatio()
{
return 0;
@@ -1112,6 +1128,15 @@ namespace MediaBrowser.Controller.Entities
var protocol = item.PathProtocol;
+ // Resolve the item path so everywhere we use the media source it will always point to
+ // the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link
+ // path will return null, so it's safe to check for all paths.
+ var itemPath = item.Path;
+ if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo)
+ {
+ itemPath = linkInfo.FullName;
+ }
+
var info = new MediaSourceInfo
{
Id = item.Id.ToString("N", CultureInfo.InvariantCulture),
@@ -1119,7 +1144,7 @@ namespace MediaBrowser.Controller.Entities
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
Name = GetMediaSourceName(item),
- Path = enablePathSubstitution ? GetMappedPath(item, item.Path, protocol) : item.Path,
+ Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks,
Container = item.Container,
Size = item.Size,
@@ -1266,7 +1291,7 @@ namespace MediaBrowser.Controller.Entities
}
/// <summary>
- /// Overrides the base implementation to refresh metadata for local trailers.
+ /// The base implementation to refresh metadata.
/// </summary>
/// <param name="options">The options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
@@ -1363,9 +1388,7 @@ namespace MediaBrowser.Controller.Entities
protected virtual FileSystemMetadata[] GetFileSystemChildren(IDirectoryService directoryService)
{
- var path = ContainingFolderPath;
-
- return directoryService.GetFileSystemEntries(path);
+ return directoryService.GetFileSystemEntries(ContainingFolderPath);
}
private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
@@ -1394,6 +1417,23 @@ namespace MediaBrowser.Controller.Entities
return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
});
+ // Cleanup removed extras
+ var removedExtraIds = item.ExtraIds.Where(e => !newExtraIds.Contains(e)).ToArray();
+ if (removedExtraIds.Length > 0)
+ {
+ var removedExtras = LibraryManager.GetItemList(new InternalItemsQuery()
+ {
+ ItemIds = removedExtraIds
+ });
+ foreach (var removedExtra in removedExtras)
+ {
+ LibraryManager.DeleteItem(removedExtra, new DeleteOptions()
+ {
+ DeleteFileLocation = false
+ });
+ }
+ }
+
await Task.WhenAll(tasks).ConfigureAwait(false);
item.ExtraIds = newExtraIds;
@@ -1408,7 +1448,14 @@ namespace MediaBrowser.Controller.Entities
public virtual bool RequiresRefresh()
{
- return false;
+ if (string.IsNullOrEmpty(Path) || DateModified == DateTime.MinValue)
+ {
+ return false;
+ }
+
+ var info = FileSystem.GetFileSystemInfo(Path);
+
+ return info.Exists && this.HasChanged(info.LastWriteTimeUtc);
}
public virtual List<string> GetUserDataKeys()
@@ -1805,7 +1852,7 @@ namespace MediaBrowser.Controller.Entities
public void SetStudios(IEnumerable<string> names)
{
- Studios = names.Trimmed().Distinct().ToArray();
+ Studios = names.Trimmed().Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
/// <summary>
@@ -1971,9 +2018,10 @@ namespace MediaBrowser.Controller.Entities
}
// Remove from file system
- if (info.IsLocalFile)
+ var path = info.Path;
+ if (info.IsLocalFile && !string.IsNullOrWhiteSpace(path))
{
- FileSystem.DeleteFile(info.Path);
+ FileSystem.DeleteFile(path);
}
// Remove from item
@@ -2051,7 +2099,7 @@ namespace MediaBrowser.Controller.Entities
{
if (imageType == ImageType.Chapter)
{
- var chapter = ChapterRepository.GetChapter(this.Id, imageIndex);
+ var chapter = ChapterManager.GetChapter(Id, imageIndex);
if (chapter is null)
{
@@ -2101,7 +2149,7 @@ namespace MediaBrowser.Controller.Entities
if (image.Type == ImageType.Chapter)
{
- var chapters = ChapterRepository.GetChapters(this.Id);
+ var chapters = ChapterManager.GetChapters(Id);
for (var i = 0; i < chapters.Count; i++)
{
if (chapters[i].ImagePath == image.Path)
@@ -2284,27 +2332,27 @@ namespace MediaBrowser.Controller.Entities
return UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None);
}
- public virtual bool IsPlayed(User user)
+ public virtual bool IsPlayed(User user, UserItemData userItemData)
{
- var userdata = UserDataManager.GetUserData(user, this);
+ userItemData ??= UserDataManager.GetUserData(user, this);
- return userdata is not null && userdata.Played;
+ return userItemData is not null && userItemData.Played;
}
- public bool IsFavoriteOrLiked(User user)
+ public bool IsFavoriteOrLiked(User user, UserItemData userItemData)
{
- var userdata = UserDataManager.GetUserData(user, this);
+ userItemData ??= UserDataManager.GetUserData(user, this);
- return userdata is not null && (userdata.IsFavorite || (userdata.Likes ?? false));
+ return userItemData is not null && (userItemData.IsFavorite || (userItemData.Likes ?? false));
}
- public virtual bool IsUnplayed(User user)
+ public virtual bool IsUnplayed(User user, UserItemData userItemData)
{
ArgumentNullException.ThrowIfNull(user);
- var userdata = UserDataManager.GetUserData(user, this);
+ userItemData ??= UserDataManager.GetUserData(user, this);
- return userdata is null || !userdata.Played;
+ return userItemData is null || !userItemData.Played;
}
ItemLookupInfo IHasLookupInfo<ItemLookupInfo>.GetLookupInfo()
diff --git a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs
index dcd22a3b4..668e2c1e2 100644
--- a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs
+++ b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs
@@ -114,5 +114,19 @@ namespace MediaBrowser.Controller.Entities
source.DeepCopy(dest);
return dest;
}
+
+ /// <summary>
+ /// Determines if the item has changed.
+ /// </summary>
+ /// <param name="source">The source object.</param>
+ /// <param name="asOf">The timestamp to detect changes as of.</param>
+ /// <typeparam name="T">Source type.</typeparam>
+ /// <returns>Whether the item has changed.</returns>
+ public static bool HasChanged<T>(this T source, DateTime asOf)
+ where T : BaseItem
+ {
+ ArgumentNullException.ThrowIfNull(source);
+ return source.DateModified.Subtract(asOf).Duration().TotalSeconds > 1;
+ }
}
}
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index 4da22854b..151b957fe 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -11,7 +11,6 @@ using System.Security;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
-using System.Threading.Tasks.Dataflow;
using J2N.Collections.Generic.Extensions;
using Jellyfin.Data;
using Jellyfin.Data.Enums;
@@ -25,6 +24,7 @@ using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LibraryTaskScheduler;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
@@ -42,6 +42,8 @@ namespace MediaBrowser.Controller.Entities
/// </summary>
public class Folder : BaseItem
{
+ private IEnumerable<BaseItem> _children;
+
public Folder()
{
LinkedChildren = Array.Empty<LinkedChild>();
@@ -49,6 +51,8 @@ namespace MediaBrowser.Controller.Entities
public static IUserViewManager UserViewManager { get; set; }
+ public static ILimitedConcurrencyLibraryScheduler LimitedConcurrencyLibraryScheduler { get; set; }
+
/// <summary>
/// Gets or sets a value indicating whether this instance is root.
/// </summary>
@@ -106,11 +110,15 @@ namespace MediaBrowser.Controller.Entities
}
/// <summary>
- /// Gets the actual children.
+ /// Gets or Sets the actual children.
/// </summary>
/// <value>The actual children.</value>
[JsonIgnore]
- public virtual IEnumerable<BaseItem> Children => LoadChildren();
+ public virtual IEnumerable<BaseItem> Children
+ {
+ get => _children ??= LoadChildren();
+ set => _children = value;
+ }
/// <summary>
/// Gets thread-safe access to all recursive children of this folder - without regard to user.
@@ -279,6 +287,7 @@ namespace MediaBrowser.Controller.Entities
/// <returns>Task.</returns>
public Task ValidateChildren(IProgress<double> progress, MetadataRefreshOptions metadataRefreshOptions, bool recursive = true, bool allowRemoveRoot = false, CancellationToken cancellationToken = default)
{
+ Children = null; // invalidate cached children.
return ValidateChildrenInternal(progress, recursive, true, allowRemoveRoot, metadataRefreshOptions, metadataRefreshOptions.DirectoryService, cancellationToken);
}
@@ -286,6 +295,7 @@ namespace MediaBrowser.Controller.Entities
{
var dictionary = new Dictionary<Guid, BaseItem>();
+ Children = null; // invalidate cached children.
var childrenList = Children.ToList();
foreach (var child in childrenList)
@@ -327,6 +337,11 @@ namespace MediaBrowser.Controller.Entities
try
{
+ if (GetParents().Any(f => f.Id.Equals(Id)))
+ {
+ throw new InvalidOperationException("Recursive datastructure detected abort processing this item.");
+ }
+
await ValidateChildrenInternal2(progress, recursive, refreshChildMetadata, allowRemoveRoot, refreshOptions, directoryService, cancellationToken).ConfigureAwait(false);
}
finally
@@ -442,6 +457,12 @@ namespace MediaBrowser.Controller.Entities
{
foreach (var item in itemsRemoved)
{
+ if (!item.CanDelete())
+ {
+ Logger.LogDebug("Item marked as non-removable, skipping: {Path}", item.Path ?? item.Name);
+ continue;
+ }
+
if (item.IsFileProtocol)
{
Logger.LogDebug("Removed item: {Path}", item.Path);
@@ -524,6 +545,7 @@ namespace MediaBrowser.Controller.Entities
{
if (validChildrenNeedGeneration)
{
+ Children = null; // invalidate cached children.
validChildren = Children.ToList();
}
@@ -566,7 +588,8 @@ namespace MediaBrowser.Controller.Entities
if (recursive && child is Folder folder)
{
- await folder.RefreshMetadataRecursive(folder.Children.ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false);
+ folder.Children = null; // invalidate cached children.
+ await folder.RefreshMetadataRecursive(folder.Children.Except([this, child]).ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -598,51 +621,13 @@ namespace MediaBrowser.Controller.Entities
/// <returns>Task.</returns>
private async Task RunTasks<T>(Func<T, IProgress<double>, Task> task, IList<T> children, IProgress<double> progress, CancellationToken cancellationToken)
{
- var childrenCount = children.Count;
- var childrenProgress = new double[childrenCount];
-
- void UpdateProgress()
- {
- progress.Report(childrenProgress.Average());
- }
-
- var fanoutConcurrency = ConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
- var parallelism = fanoutConcurrency > 0 ? fanoutConcurrency : Environment.ProcessorCount;
-
- var actionBlock = new ActionBlock<int>(
- async i =>
- {
- var innerProgress = new Progress<double>(innerPercent =>
- {
- // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls
- var innerPercentRounded = Math.Round(innerPercent);
- if (childrenProgress[i] != innerPercentRounded)
- {
- childrenProgress[i] = innerPercentRounded;
- UpdateProgress();
- }
- });
-
- await task(children[i], innerProgress).ConfigureAwait(false);
-
- childrenProgress[i] = 100;
-
- UpdateProgress();
- },
- new ExecutionDataflowBlockOptions
- {
- MaxDegreeOfParallelism = parallelism,
- CancellationToken = cancellationToken,
- });
-
- for (var i = 0; i < childrenCount; i++)
- {
- await actionBlock.SendAsync(i, cancellationToken).ConfigureAwait(false);
- }
-
- actionBlock.Complete();
-
- await actionBlock.Completion.ConfigureAwait(false);
+ await LimitedConcurrencyLibraryScheduler
+ .Enqueue(
+ children.ToArray(),
+ task,
+ progress,
+ cancellationToken)
+ .ConfigureAwait(false);
}
/// <summary>
@@ -722,16 +707,29 @@ namespace MediaBrowser.Controller.Entities
IEnumerable<BaseItem> items;
Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
+ var totalCount = 0;
if (query.User is null)
{
items = GetRecursiveChildren(filter);
+ totalCount = items.Count();
}
else
{
- items = GetRecursiveChildren(user, query);
+ // Save pagination params before clearing them to prevent pagination from happening
+ // before sorting. PostFilterAndSort will apply pagination after sorting.
+ var limit = query.Limit;
+ var startIndex = query.StartIndex;
+ query.Limit = null;
+ query.StartIndex = null;
+
+ items = GetRecursiveChildren(user, query, out totalCount);
+
+ // Restore pagination params so PostFilterAndSort can apply them after sorting
+ query.Limit = limit;
+ query.StartIndex = startIndex;
}
- return PostFilterAndSort(items, query, true);
+ return PostFilterAndSort(items, query);
}
if (this is not UserRootFolder
@@ -980,25 +978,31 @@ namespace MediaBrowser.Controller.Entities
IEnumerable<BaseItem> items;
+ int totalItemCount = 0;
if (query.User is null)
{
items = Children.Where(filter);
+ totalItemCount = items.Count();
}
else
{
// need to pass this param to the children.
+ // Note: Don't pass Limit/StartIndex here as pagination should happen after sorting in PostFilterAndSort
var childQuery = new InternalItemsQuery
{
- DisplayAlbumFolders = query.DisplayAlbumFolders
+ DisplayAlbumFolders = query.DisplayAlbumFolders,
+ NameStartsWith = query.NameStartsWith,
+ NameStartsWithOrGreater = query.NameStartsWithOrGreater,
+ NameLessThan = query.NameLessThan
};
- items = GetChildren(user, true, childQuery).Where(filter);
+ items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter);
}
- return PostFilterAndSort(items, query, true);
+ return PostFilterAndSort(items, query);
}
- protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query, bool enableSorting)
+ protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query)
{
var user = query.User;
@@ -1008,7 +1012,7 @@ namespace MediaBrowser.Controller.Entities
items = CollapseBoxSetItemsIfNeeded(items, query, this, user, ConfigurationManager, CollectionManager);
}
- #pragma warning disable CA1309
+#pragma warning disable CA1309
if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater))
{
items = items.Where(i => string.Compare(query.NameStartsWithOrGreater, i.SortName, StringComparison.InvariantCultureIgnoreCase) < 1);
@@ -1023,7 +1027,7 @@ namespace MediaBrowser.Controller.Entities
{
items = items.Where(i => string.Compare(query.NameLessThan, i.SortName, StringComparison.InvariantCultureIgnoreCase) == 1);
}
- #pragma warning restore CA1309
+#pragma warning restore CA1309
// This must be the last filter
if (!query.AdjacentTo.IsNullOrEmpty())
@@ -1031,7 +1035,15 @@ namespace MediaBrowser.Controller.Entities
items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
}
- return UserViewBuilder.SortAndPage(items, null, query, LibraryManager, enableSorting);
+ var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
+ var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
+
+ if (query.EnableTotalRecordCount)
+ {
+ result.TotalRecordCount = filteredItems.Count;
+ }
+
+ return result;
}
private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
@@ -1044,12 +1056,49 @@ namespace MediaBrowser.Controller.Entities
{
ArgumentNullException.ThrowIfNull(items);
- if (CollapseBoxSetItems(query, queryParent, user, configurationManager))
+ if (!CollapseBoxSetItems(query, queryParent, user, configurationManager))
+ {
+ return items;
+ }
+
+ var config = configurationManager.Configuration;
+
+ bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
+ bool collapseSeries = config.EnableGroupingShowsIntoCollections;
+
+ if (user is null || (collapseMovies && collapseSeries))
+ {
+ return collectionManager.CollapseItemsWithinBoxSets(items, user);
+ }
+
+ if (!collapseMovies && !collapseSeries)
{
- items = collectionManager.CollapseItemsWithinBoxSets(items, user);
+ return items;
}
- return items;
+ var collapsibleItems = new List<BaseItem>();
+ var remainingItems = new List<BaseItem>();
+
+ foreach (var item in items)
+ {
+ if ((collapseMovies && item is Movie) || (collapseSeries && item is Series))
+ {
+ collapsibleItems.Add(item);
+ }
+ else
+ {
+ remainingItems.Add(item);
+ }
+ }
+
+ if (collapsibleItems.Count == 0)
+ {
+ return remainingItems;
+ }
+
+ var collapsedItems = collectionManager.CollapseItemsWithinBoxSets(collapsibleItems, user);
+
+ return collapsedItems.Concat(remainingItems);
}
private static bool CollapseBoxSetItems(
@@ -1080,24 +1129,26 @@ namespace MediaBrowser.Controller.Entities
}
var param = query.CollapseBoxSetItems;
-
- if (!param.HasValue)
+ if (param.HasValue)
{
- if (user is not null && query.IncludeItemTypes.Any(type =>
- (type == BaseItemKind.Movie && !configurationManager.Configuration.EnableGroupingMoviesIntoCollections) ||
- (type == BaseItemKind.Series && !configurationManager.Configuration.EnableGroupingShowsIntoCollections)))
- {
- return false;
- }
+ return param.Value && AllowBoxSetCollapsing(query);
+ }
- if (query.IncludeItemTypes.Length == 0
- || query.IncludeItemTypes.Any(type => type == BaseItemKind.Movie || type == BaseItemKind.Series))
- {
- param = true;
- }
+ var config = configurationManager.Configuration;
+
+ bool queryHasMovies = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Movie);
+ bool queryHasSeries = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Series);
+
+ bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
+ bool collapseSeries = config.EnableGroupingShowsIntoCollections;
+
+ if (user is not null)
+ {
+ bool canCollapse = (queryHasMovies && collapseMovies) || (queryHasSeries && collapseSeries);
+ return canCollapse && AllowBoxSetCollapsing(query);
}
- return param.HasValue && param.Value && AllowBoxSetCollapsing(query);
+ return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query);
}
private static bool AllowBoxSetCollapsing(InternalItemsQuery request)
@@ -1222,11 +1273,6 @@ namespace MediaBrowser.Controller.Entities
return false;
}
- if (request.IsPlayed.HasValue)
- {
- return false;
- }
-
if (!string.IsNullOrWhiteSpace(request.Person))
{
return false;
@@ -1267,17 +1313,15 @@ namespace MediaBrowser.Controller.Entities
return false;
}
- if (request.MinCommunityRating.HasValue)
- {
- return false;
- }
-
- if (request.MinCriticRating.HasValue)
+ if (request.MinIndexNumber.HasValue)
{
return false;
}
- if (request.MinIndexNumber.HasValue)
+ if (request.OrderBy.Any(o =>
+ o.OrderBy == ItemSortBy.CommunityRating ||
+ o.OrderBy == ItemSortBy.CriticRating ||
+ o.OrderBy == ItemSortBy.Runtime))
{
return false;
}
@@ -1285,30 +1329,30 @@ namespace MediaBrowser.Controller.Entities
return true;
}
- public IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren)
- {
- ArgumentNullException.ThrowIfNull(user);
-
- return GetChildren(user, includeLinkedChildren, new InternalItemsQuery(user));
- }
-
- public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
+ public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount, InternalItemsQuery query = null)
{
ArgumentNullException.ThrowIfNull(user);
+ query ??= new InternalItemsQuery();
+ query.User = user;
// the true root should return our users root folder children
if (IsPhysicalRoot)
{
- return LibraryManager.GetUserRootFolder().GetChildren(user, includeLinkedChildren);
+ return LibraryManager.GetUserRootFolder().GetChildren(user, includeLinkedChildren, out totalItemCount);
}
var result = new Dictionary<Guid, BaseItem>();
- AddChildren(user, includeLinkedChildren, result, false, query);
+ totalItemCount = AddChildren(user, includeLinkedChildren, result, false, query);
return result.Values.ToArray();
}
+ public virtual IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query = null)
+ {
+ return GetChildren(user, includeLinkedChildren, out _, query);
+ }
+
protected virtual IEnumerable<BaseItem> GetEligibleChildrenForRecursiveChildren(User user)
{
return Children;
@@ -1317,13 +1361,13 @@ namespace MediaBrowser.Controller.Entities
/// <summary>
/// Adds the children to list.
/// </summary>
- private void AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders = null)
+ private int AddChildren(User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders = null)
{
// Prevent infinite recursion of nested folders
visitedFolders ??= new HashSet<Folder>();
if (!visitedFolders.Add(this))
{
- return;
+ return 0;
}
// If Query.AlbumFolders is set, then enforce the format as per the db in that it permits sub-folders in music albums.
@@ -1340,44 +1384,67 @@ namespace MediaBrowser.Controller.Entities
children = GetEligibleChildrenForRecursiveChildren(user);
}
- AddChildrenFromCollection(children, user, includeLinkedChildren, result, recursive, query, visitedFolders);
-
if (includeLinkedChildren)
{
- AddChildrenFromCollection(GetLinkedChildren(user), user, includeLinkedChildren, result, recursive, query, visitedFolders);
+ children = children.Concat(GetLinkedChildren(user)).ToArray();
}
+
+ return AddChildrenFromCollection(children, user, includeLinkedChildren, result, recursive, query, visitedFolders);
}
- private void AddChildrenFromCollection(IEnumerable<BaseItem> children, User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders)
+ private int AddChildrenFromCollection(IEnumerable<BaseItem> children, User user, bool includeLinkedChildren, Dictionary<Guid, BaseItem> result, bool recursive, InternalItemsQuery query, HashSet<Folder> visitedFolders)
{
- foreach (var child in children)
+ query ??= new InternalItemsQuery();
+ var limit = query.Limit > 0 ? query.Limit : int.MaxValue;
+ query.Limit = 0;
+
+ var visibleChildren = children
+ .Where(e => e.IsVisible(user))
+ .ToArray();
+
+ var realChildren = visibleChildren
+ .Where(e => query is null || UserViewBuilder.FilterItem(e, query))
+ .ToArray();
+
+ if (this is BoxSet && (query.OrderBy is null || query.OrderBy.Count == 0))
{
- if (!child.IsVisible(user))
- {
- continue;
- }
+ realChildren = realChildren
+ .OrderBy(e => e.ProductionYear ?? int.MaxValue)
+ .ToArray();
+ }
- if (query is null || UserViewBuilder.FilterItem(child, query))
+ var childCount = realChildren.Length;
+ if (result.Count < limit)
+ {
+ var remainingCount = (int)(limit - result.Count);
+ foreach (var child in realChildren
+ .Skip(query.StartIndex ?? 0)
+ .Take(remainingCount))
{
result[child.Id] = child;
}
+ }
- if (recursive && child.IsFolder)
+ if (recursive)
+ {
+ foreach (var child in visibleChildren
+ .Where(e => e.IsFolder)
+ .OfType<Folder>())
{
- var folder = (Folder)child;
-
- folder.AddChildren(user, includeLinkedChildren, result, true, query, visitedFolders);
+ childCount += child.AddChildren(user, includeLinkedChildren, result, true, query, visitedFolders);
}
}
+
+ return childCount;
}
- public virtual IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query)
+ public virtual IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
{
ArgumentNullException.ThrowIfNull(user);
var result = new Dictionary<Guid, BaseItem>();
- AddChildren(user, true, result, true, query);
+ totalCount = AddChildren(user, true, result, true, query);
return result.Values.ToArray();
}
@@ -1709,23 +1776,14 @@ namespace MediaBrowser.Controller.Entities
}
}
- public override bool IsPlayed(User user)
+ public override bool IsPlayed(User user, UserItemData userItemData)
{
- var itemsResult = GetItemList(new InternalItemsQuery(user)
- {
- Recursive = true,
- IsFolder = false,
- IsVirtualItem = false,
- EnableTotalRecordCount = false
- });
-
- return itemsResult
- .All(i => i.IsPlayed(user));
+ return ItemRepository.GetIsPlayed(user, Id, true);
}
- public override bool IsUnplayed(User user)
+ public override bool IsUnplayed(User user, UserItemData userItemData)
{
- return !IsPlayed(user);
+ return !IsPlayed(user, userItemData);
}
public override void FillUserDataDtoValues(UserItemDataDto dto, UserItemData userData, BaseItemDto itemDto, User user, DtoOptions fields)
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index d50f3d075..b32b64f5d 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using Diacritics.Extensions;
using Jellyfin.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
@@ -373,8 +374,15 @@ namespace MediaBrowser.Controller.Entities
.Where(i => i != other)
.Select(e => Enum.Parse<UnratedItem>(e, true)).ToArray();
- ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags);
- IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags);
+ ExcludeInheritedTags = user.GetPreference(PreferenceKind.BlockedTags)
+ .Where(tag => !string.IsNullOrWhiteSpace(tag))
+ .Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
+ .ToArray();
+
+ IncludeInheritedTags = user.GetPreference(PreferenceKind.AllowedTags)
+ .Where(tag => !string.IsNullOrWhiteSpace(tag))
+ .Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
+ .ToArray();
User = user;
}
diff --git a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
index d656fccb4..1d1fb2c39 100644
--- a/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
+++ b/MediaBrowser.Controller/Entities/Movies/BoxSet.cs
@@ -136,9 +136,9 @@ namespace MediaBrowser.Controller.Entities.Movies
return Sort(children, user).ToArray();
}
- public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query)
+ public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
{
- var children = base.GetRecursiveChildren(user, query);
+ var children = base.GetRecursiveChildren(user, query, out totalCount);
return Sort(children, user).ToArray();
}
@@ -197,7 +197,7 @@ namespace MediaBrowser.Controller.Entities.Movies
var expandedFolders = new List<Guid>();
return FlattenItems(this, expandedFolders)
- .SelectMany(i => LibraryManager.GetCollectionFolders(i))
+ .SelectMany(LibraryManager.GetCollectionFolders)
.Select(i => i.Id)
.Distinct()
.ToArray();
diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs
index 46bad3f3b..6bdba36f9 100644
--- a/MediaBrowser.Controller/Entities/TV/Episode.cs
+++ b/MediaBrowser.Controller/Entities/TV/Episode.cs
@@ -7,12 +7,14 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
+using System.Threading;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
+using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Providers;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Controller.Entities.TV
@@ -22,6 +24,8 @@ namespace MediaBrowser.Controller.Entities.TV
/// </summary>
public class Episode : Video, IHasTrailers, IHasLookupInfo<EpisodeInfo>, IHasSeries
{
+ public static IMediaEncoder MediaEncoder { get; set; }
+
/// <inheritdoc />
[JsonIgnore]
public IReadOnlyList<BaseItem> LocalTrailers => GetExtras()
@@ -325,6 +329,39 @@ namespace MediaBrowser.Controller.Entities.TV
{
if (SourceType == SourceType.Library || SourceType == SourceType.LiveTV)
{
+ var libraryOptions = LibraryManager.GetLibraryOptions(this);
+ if (libraryOptions.EnableEmbeddedEpisodeInfos && string.Equals(Container, "mp4", StringComparison.OrdinalIgnoreCase))
+ {
+ try
+ {
+ var mediaInfo = MediaEncoder.GetMediaInfo(
+ new MediaInfoRequest
+ {
+ MediaSource = GetMediaSources(false)[0],
+ MediaType = DlnaProfileType.Video
+ },
+ CancellationToken.None).GetAwaiter().GetResult();
+ if (mediaInfo.ParentIndexNumber > 0)
+ {
+ ParentIndexNumber = mediaInfo.ParentIndexNumber;
+ }
+
+ if (mediaInfo.IndexNumber > 0)
+ {
+ IndexNumber = mediaInfo.IndexNumber;
+ }
+
+ if (!string.IsNullOrEmpty(mediaInfo.ShowName))
+ {
+ SeriesName = mediaInfo.ShowName;
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error reading the episode information with ffprobe. Episode: {EpisodeInfo}", Path);
+ }
+ }
+
try
{
if (LibraryManager.FillMissingEpisodeNumbersFromPath(this, replaceAllMetadata))
diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs
index 408161b03..b972ebaa6 100644
--- a/MediaBrowser.Controller/Entities/TV/Season.cs
+++ b/MediaBrowser.Controller/Entities/TV/Season.cs
@@ -123,7 +123,7 @@ namespace MediaBrowser.Controller.Entities.TV
public override int GetChildCount(User user)
{
- var result = GetChildren(user, true).Count;
+ var result = GetChildren(user, true, null).Count;
return result;
}
@@ -179,7 +179,7 @@ namespace MediaBrowser.Controller.Entities.TV
var items = GetEpisodes(user, query.DtoOptions, true).Where(filter);
- return PostFilterAndSort(items, query, false);
+ return PostFilterAndSort(items, query);
}
/// <summary>
diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index b4ad05921..427c2995b 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey;
query.IncludeItemTypes = new[] { BaseItemKind.Season };
- query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
+ query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) };
if (user is not null && !user.DisplayMissingEpisodes)
{
@@ -247,10 +247,6 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey;
- if (query.OrderBy.Count == 0)
- {
- query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
- }
if (query.IncludeItemTypes.Length == 0)
{
@@ -301,6 +297,7 @@ namespace MediaBrowser.Controller.Entities.TV
public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
{
+ Children = null; // invalidate cached children.
// Refresh bottom up, seasons and episodes first, then the series
var items = GetRecursiveChildren();
diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs
index bc7e22d9a..deed3631b 100644
--- a/MediaBrowser.Controller/Entities/UserRootFolder.cs
+++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs
@@ -80,7 +80,7 @@ namespace MediaBrowser.Controller.Entities
PresetViews = query.PresetViews
});
- return UserViewBuilder.SortAndPage(result, null, query, LibraryManager, true);
+ return UserViewBuilder.SortAndPage(result, null, query, LibraryManager);
}
public override int GetChildCount(User user)
diff --git a/MediaBrowser.Controller/Entities/UserView.cs b/MediaBrowser.Controller/Entities/UserView.cs
index dfa31315c..5624f8b2e 100644
--- a/MediaBrowser.Controller/Entities/UserView.cs
+++ b/MediaBrowser.Controller/Entities/UserView.cs
@@ -89,7 +89,7 @@ namespace MediaBrowser.Controller.Entities
/// <inheritdoc />
public override int GetChildCount(User user)
{
- return GetChildren(user, true).Count;
+ return GetChildren(user, true, null).Count;
}
/// <inheritdoc />
@@ -134,20 +134,22 @@ namespace MediaBrowser.Controller.Entities
}
/// <inheritdoc />
- public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query)
+ public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
{
query.SetUser(user);
query.Recursive = true;
query.EnableTotalRecordCount = false;
query.ForceDirect = true;
+ var data = GetItemList(query);
+ totalCount = data.Count;
- return GetItemList(query);
+ return data;
}
/// <inheritdoc />
protected override IReadOnlyList<BaseItem> GetEligibleChildrenForRecursiveChildren(User user)
{
- return GetChildren(user, false);
+ return GetChildren(user, false, null);
}
public static bool IsUserSpecific(Folder folder)
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index c2b4da32a..4f9e9261b 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -438,22 +438,18 @@ namespace MediaBrowser.Controller.Entities
items = FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
}
- return SortAndPage(items, totalRecordLimit, query, libraryManager, true);
+ return SortAndPage(items, totalRecordLimit, query, libraryManager);
}
public static QueryResult<BaseItem> SortAndPage(
IEnumerable<BaseItem> items,
int? totalRecordLimit,
InternalItemsQuery query,
- ILibraryManager libraryManager,
- bool enableSorting)
+ ILibraryManager libraryManager)
{
- if (enableSorting)
+ if (query.OrderBy.Count > 0)
{
- if (query.OrderBy.Count > 0)
- {
- items = libraryManager.Sort(items, query.User, query.OrderBy);
- }
+ items = libraryManager.Sort(items, query.User, query.OrderBy);
}
var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray();
@@ -476,6 +472,23 @@ namespace MediaBrowser.Controller.Entities
public static bool Filter(BaseItem item, User user, InternalItemsQuery query, IUserDataManager userDataManager, ILibraryManager libraryManager)
{
+ if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
+ {
+ return false;
+ }
+
+#pragma warning disable CA1309 // Use ordinal string comparison
+ if (!string.IsNullOrEmpty(query.NameStartsWithOrGreater) && string.Compare(query.NameStartsWithOrGreater, item.SortName, StringComparison.InvariantCultureIgnoreCase) == 1)
+ {
+ return false;
+ }
+
+ if (!string.IsNullOrEmpty(query.NameLessThan) && string.Compare(query.NameLessThan, item.SortName, StringComparison.InvariantCultureIgnoreCase) != 1)
+#pragma warning restore CA1309 // Use ordinal string comparison
+ {
+ return false;
+ }
+
if (query.MediaTypes.Length > 0 && !query.MediaTypes.Contains(item.MediaType))
{
return false;
@@ -506,7 +519,6 @@ namespace MediaBrowser.Controller.Entities
if (query.IsLiked.HasValue)
{
userData = userDataManager.GetUserData(user, item);
-
if (!userData.Likes.HasValue || userData.Likes != query.IsLiked.Value)
{
return false;
@@ -515,7 +527,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IsFavoriteOrLiked.HasValue)
{
- userData = userData ?? userDataManager.GetUserData(user, item);
+ userData ??= userDataManager.GetUserData(user, item);
var isFavoriteOrLiked = userData.IsFavorite || (userData.Likes ?? false);
if (isFavoriteOrLiked != query.IsFavoriteOrLiked.Value)
@@ -526,8 +538,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IsFavorite.HasValue)
{
- userData = userData ?? userDataManager.GetUserData(user, item);
-
+ userData ??= userDataManager.GetUserData(user, item);
if (userData.IsFavorite != query.IsFavorite.Value)
{
return false;
@@ -536,7 +547,7 @@ namespace MediaBrowser.Controller.Entities
if (query.IsResumable.HasValue)
{
- userData = userData ?? userDataManager.GetUserData(user, item);
+ userData ??= userDataManager.GetUserData(user, item);
var isResumable = userData.PlaybackPositionTicks > 0;
if (isResumable != query.IsResumable.Value)
@@ -547,7 +558,8 @@ namespace MediaBrowser.Controller.Entities
if (query.IsPlayed.HasValue)
{
- if (item.IsPlayed(user) != query.IsPlayed.Value)
+ userData ??= userDataManager.GetUserData(user, item);
+ if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
{
return false;
}
@@ -924,6 +936,11 @@ namespace MediaBrowser.Controller.Entities
}
}
+ if (query.ExcludeItemIds.Contains(item.Id))
+ {
+ return false;
+ }
+
return true;
}
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 04f47b729..1043029c6 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -152,16 +152,7 @@ namespace MediaBrowser.Controller.Entities
{
get
{
- if (!string.IsNullOrEmpty(PrimaryVersionId))
- {
- var item = LibraryManager.GetItemById(PrimaryVersionId);
- if (item is Video video)
- {
- return video.MediaSourceCount;
- }
- }
-
- return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1;
+ return GetMediaSourceCount();
}
}
@@ -259,6 +250,27 @@ namespace MediaBrowser.Controller.Entities
[JsonIgnore]
public override MediaType MediaType => MediaType.Video;
+ private int GetMediaSourceCount(HashSet<Guid> callstack = null)
+ {
+ callstack ??= new();
+ if (!string.IsNullOrEmpty(PrimaryVersionId))
+ {
+ var item = LibraryManager.GetItemById(PrimaryVersionId);
+ if (item is Video video)
+ {
+ if (callstack.Contains(video.Id))
+ {
+ return video.LinkedAlternateVersions.Length + video.LocalAlternateVersions.Length + 1;
+ }
+
+ callstack.Add(video.Id);
+ return video.GetMediaSourceCount(callstack);
+ }
+ }
+
+ return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1;
+ }
+
public override List<string> GetUserDataKeys()
{
var list = base.GetUserDataKeys();
diff --git a/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs
index 2742f21e3..b53210b0b 100644
--- a/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs
+++ b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs
@@ -167,12 +167,12 @@ public static class XmlReaderExtensions
// Only split by comma if there is no pipe in the string
// We have to be careful to not split names like Matthew, Jr.
- var separator = !value.Contains('|', StringComparison.Ordinal)
+ ReadOnlySpan<char> separator = !value.Contains('|', StringComparison.Ordinal)
&& !value.Contains(';', StringComparison.Ordinal)
- ? new[] { ',' }
- : new[] { '|', ';' };
+ ? stackalloc[] { ',' }
+ : stackalloc[] { '|', ';' };
- foreach (var part in value.Trim().Trim(separator).Split(separator))
+ foreach (var part in value.AsSpan().Trim().Trim(separator).ToString().Split(separator))
{
if (!string.IsNullOrWhiteSpace(part))
{
diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs
index a97096eae..7e235ed26 100644
--- a/MediaBrowser.Controller/IDisplayPreferencesManager.cs
+++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs
@@ -60,8 +60,15 @@ namespace MediaBrowser.Controller
void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string?> customPreferences);
/// <summary>
- /// Saves changes made to the database.
+ /// Updates or Creates the display preferences.
/// </summary>
- void SaveChanges();
+ /// <param name="displayPreferences">The entity to update or create.</param>
+ void UpdateDisplayPreferences(DisplayPreferences displayPreferences);
+
+ /// <summary>
+ /// Updates or Creates the display preferences for the given item.
+ /// </summary>
+ /// <param name="itemDisplayPreferences">The entity to update or create.</param>
+ void UpdateItemDisplayPreferences(ItemDisplayPreferences itemDisplayPreferences);
}
}
diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs
index 1a33c3aa8..3e390ca42 100644
--- a/MediaBrowser.Controller/IO/FileSystemHelper.cs
+++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using MediaBrowser.Model.IO;
@@ -61,4 +62,82 @@ public static class FileSystemHelper
}
}
}
+
+ /// <summary>
+ /// Gets the target of the specified file link.
+ /// </summary>
+ /// <remarks>
+ /// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128.
+ /// </remarks>
+ /// <param name="linkPath">The path of the file link.</param>
+ /// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param>
+ /// <returns>
+ /// A <see cref="FileInfo"/> if the <paramref name="linkPath"/> is a link, regardless of if the target exists; otherwise, <c>null</c>.
+ /// </returns>
+ public static FileInfo? ResolveLinkTarget(string linkPath, bool returnFinalTarget = false)
+ {
+ // Check if the file exists so the native resolve handler won't throw at us.
+ if (!File.Exists(linkPath))
+ {
+ return null;
+ }
+
+ if (!returnFinalTarget)
+ {
+ return File.ResolveLinkTarget(linkPath, returnFinalTarget: false) as FileInfo;
+ }
+
+ if (File.ResolveLinkTarget(linkPath, returnFinalTarget: false) is not FileInfo targetInfo)
+ {
+ return null;
+ }
+
+ if (!targetInfo.Exists)
+ {
+ return targetInfo;
+ }
+
+ var currentPath = targetInfo.FullName;
+ var visited = new HashSet<string>(StringComparer.Ordinal) { linkPath, currentPath };
+ while (File.ResolveLinkTarget(currentPath, returnFinalTarget: false) is FileInfo linkInfo)
+ {
+ var targetPath = linkInfo.FullName;
+
+ // If an infinite loop is detected, return the file info for the
+ // first link in the loop we encountered.
+ if (!visited.Add(targetPath))
+ {
+ return new FileInfo(targetPath);
+ }
+
+ targetInfo = linkInfo;
+ currentPath = targetPath;
+
+ // Exit if the target doesn't exist, so the native resolve handler won't throw at us.
+ if (!targetInfo.Exists)
+ {
+ break;
+ }
+ }
+
+ return targetInfo;
+ }
+
+ /// <summary>
+ /// Gets the target of the specified file link.
+ /// </summary>
+ /// <remarks>
+ /// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128.
+ /// </remarks>
+ /// <param name="fileInfo">The file info of the file link.</param>
+ /// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param>
+ /// <returns>
+ /// A <see cref="FileInfo"/> if the <paramref name="fileInfo"/> is a link, regardless of if the target exists; otherwise, <c>null</c>.
+ /// </returns>
+ public static FileInfo? ResolveLinkTarget(FileInfo fileInfo, bool returnFinalTarget = false)
+ {
+ ArgumentNullException.ThrowIfNull(fileInfo);
+
+ return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget);
+ }
}
diff --git a/MediaBrowser.Controller/IO/IExternalDataManager.cs b/MediaBrowser.Controller/IO/IExternalDataManager.cs
new file mode 100644
index 000000000..f69f4586c
--- /dev/null
+++ b/MediaBrowser.Controller/IO/IExternalDataManager.cs
@@ -0,0 +1,19 @@
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.IO;
+
+/// <summary>
+/// Interface IPathManager.
+/// </summary>
+public interface IExternalDataManager
+{
+ /// <summary>
+ /// Deletes all external item data.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken);
+}
diff --git a/MediaBrowser.Controller/IO/IPathManager.cs b/MediaBrowser.Controller/IO/IPathManager.cs
index 7c20164a6..eb6743754 100644
--- a/MediaBrowser.Controller/IO/IPathManager.cs
+++ b/MediaBrowser.Controller/IO/IPathManager.cs
@@ -1,10 +1,10 @@
+using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
-using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.IO;
/// <summary>
-/// Interface ITrickplayManager.
+/// Interface IPathManager.
/// </summary>
public interface IPathManager
{
@@ -46,4 +46,26 @@ public interface IPathManager
/// <param name="mediaSourceId">The media source id.</param>
/// <returns>The absolute path.</returns>
public string GetAttachmentFolderPath(string mediaSourceId);
+
+ /// <summary>
+ /// Gets the chapter images data path.
+ /// </summary>
+ /// <param name="item">The base item.</param>
+ /// <returns>The chapter images data path.</returns>
+ public string GetChapterImageFolderPath(BaseItem item);
+
+ /// <summary>
+ /// Gets the chapter images path.
+ /// </summary>
+ /// <param name="item">The base item.</param>
+ /// <param name="chapterPositionTicks">The chapter position.</param>
+ /// <returns>The chapter images data path.</returns>
+ public string GetChapterImagePath(BaseItem item, long chapterPositionTicks);
+
+ /// <summary>
+ /// Gets the paths of extracted data folders.
+ /// </summary>
+ /// <param name="item">The base item.</param>
+ /// <returns>The absolute paths.</returns>
+ public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item);
}
diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs
index e9c4d9e19..b76141db0 100644
--- a/MediaBrowser.Controller/IServerApplicationHost.cs
+++ b/MediaBrowser.Controller/IServerApplicationHost.cs
@@ -39,6 +39,11 @@ namespace MediaBrowser.Controller
string FriendlyName { get; }
/// <summary>
+ /// Gets or sets the path to the backup archive used to restore upon restart.
+ /// </summary>
+ string RestoreBackupPath { get; set; }
+
+ /// <summary>
/// Gets a URL specific for the request.
/// </summary>
/// <param name="request">The <see cref="HttpRequest"/> instance.</param>
diff --git a/MediaBrowser.Controller/IServerApplicationPaths.cs b/MediaBrowser.Controller/IServerApplicationPaths.cs
index 608286cd8..a6e83a02c 100644
--- a/MediaBrowser.Controller/IServerApplicationPaths.cs
+++ b/MediaBrowser.Controller/IServerApplicationPaths.cs
@@ -2,6 +2,10 @@
#pragma warning disable CS1591
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
using MediaBrowser.Common.Configuration;
namespace MediaBrowser.Controller
diff --git a/MediaBrowser.Controller/Library/IKeyframeManager.cs b/MediaBrowser.Controller/Library/IKeyframeManager.cs
new file mode 100644
index 000000000..b0155efdd
--- /dev/null
+++ b/MediaBrowser.Controller/Library/IKeyframeManager.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.MediaEncoding.Keyframes;
+
+namespace MediaBrowser.Controller.IO;
+
+/// <summary>
+/// Interface IKeyframeManager.
+/// </summary>
+public interface IKeyframeManager
+{
+ /// <summary>
+ /// Gets the keyframe data.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <returns>The keyframe data.</returns>
+ IReadOnlyList<KeyframeData> GetKeyframeData(Guid itemId);
+
+ /// <summary>
+ /// Saves the keyframe data.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="data">The keyframe data.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The task object representing the asynchronous operation.</returns>
+ Task SaveKeyframeDataAsync(Guid itemId, KeyframeData data, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Deletes the keyframe data.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The task object representing the asynchronous operation.</returns>
+ Task DeleteKeyframeDataAsync(Guid itemId, CancellationToken cancellationToken);
+}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index df90f546c..fcc5ed672 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -220,13 +220,13 @@ namespace MediaBrowser.Controller.Library
/// <param name="resolvers">The resolvers.</param>
/// <param name="introProviders">The intro providers.</param>
/// <param name="itemComparers">The item comparers.</param>
- /// <param name="postscanTasks">The postscan tasks.</param>
+ /// <param name="postScanTasks">The post scan tasks.</param>
void AddParts(
IEnumerable<IResolverIgnoreRule> rules,
IEnumerable<IItemResolver> resolvers,
IEnumerable<IIntroProvider> introProviders,
IEnumerable<IBaseItemComparer> itemComparers,
- IEnumerable<ILibraryPostScanTask> postscanTasks);
+ IEnumerable<ILibraryPostScanTask> postScanTasks);
/// <summary>
/// Sorts the specified items.
@@ -337,6 +337,13 @@ namespace MediaBrowser.Controller.Library
void DeleteItem(BaseItem item, DeleteOptions options);
/// <summary>
+ /// Deletes items that are not having any children like Actors.
+ /// </summary>
+ /// <param name="items">Items to delete.</param>
+ /// <remarks>In comparison to <see cref="DeleteItem(BaseItem, DeleteOptions, BaseItem, bool)"/> this method skips a lot of steps assuming there are no children to recusively delete nor does it define the special handling for channels and alike.</remarks>
+ public void DeleteItemsUnsafeFast(IEnumerable<BaseItem> items);
+
+ /// <summary>
/// Deletes the item.
/// </summary>
/// <param name="item">Item to delete.</param>
@@ -593,11 +600,11 @@ namespace MediaBrowser.Controller.Library
QueryResult<BaseItem> GetItemsResult(InternalItemsQuery query);
/// <summary>
- /// Ignores the file.
+ /// Checks if the file is ignored.
/// </summary>
/// <param name="file">The file.</param>
/// <param name="parent">The parent.</param>
- /// <returns><c>true</c> if XXXX, <c>false</c> otherwise.</returns>
+ /// <returns><c>true</c> if ignored, <c>false</c> otherwise.</returns>
bool IgnoreFile(FileSystemMetadata file, BaseItem parent);
Guid GetStudioId(string name);
@@ -624,12 +631,16 @@ namespace MediaBrowser.Controller.Library
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetArtists(InternalItemsQuery query);
+ IReadOnlyDictionary<string, MusicArtist[]> GetArtists(IReadOnlyList<string> names);
+
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAllArtists(InternalItemsQuery query);
int GetCount(InternalItemsQuery query);
+ ItemCounts GetItemCounts(InternalItemsQuery query);
+
Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason);
BaseItem GetParentItem(Guid? parentId, Guid? userId);
diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs
new file mode 100644
index 000000000..e7460a2e6
--- /dev/null
+++ b/MediaBrowser.Controller/LibraryTaskScheduler/ILimitedConcurrencyLibraryScheduler.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Model.Configuration;
+
+namespace MediaBrowser.Controller.LibraryTaskScheduler;
+
+/// <summary>
+/// Provides a shared scheduler to run library related tasks based on the <see cref="ServerConfiguration.LibraryScanFanoutConcurrency"/>.
+/// </summary>
+public interface ILimitedConcurrencyLibraryScheduler
+{
+ /// <summary>
+ /// Enqueues an action that will be invoked with the set data.
+ /// </summary>
+ /// <typeparam name="T">The data Type.</typeparam>
+ /// <param name="data">The data.</param>
+ /// <param name="worker">The callback to process the data.</param>
+ /// <param name="progress">A progress reporter.</param>
+ /// <param name="cancellationToken">Stop token.</param>
+ /// <returns>A task that finishes when all data has been processed by the worker.</returns>
+ Task Enqueue<T>(T[] data, Func<T, IProgress<double>, Task> worker, IProgress<double> progress, CancellationToken cancellationToken);
+}
diff --git a/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
new file mode 100644
index 000000000..0de5f198d
--- /dev/null
+++ b/MediaBrowser.Controller/LibraryTaskScheduler/LimitedConcurrencyLibraryScheduler.cs
@@ -0,0 +1,335 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.LibraryTaskScheduler;
+
+/// <summary>
+/// Provides Parallel action interface to process tasks with a set concurrency level.
+/// </summary>
+public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable
+{
+ private const int CleanupGracePeriod = 60;
+ private readonly IHostApplicationLifetime _hostApplicationLifetime;
+ private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger;
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly Dictionary<CancellationTokenSource, Task> _taskRunners = new();
+
+ private static readonly AsyncLocal<CancellationTokenSource> _deadlockDetector = new();
+
+ /// <summary>
+ /// Gets used to lock all operations on the Tasks queue and creating workers.
+ /// </summary>
+ private readonly Lock _taskLock = new();
+
+ private readonly BlockingCollection<TaskQueueItem> _tasks = new();
+
+ private volatile int _workCounter;
+ private Task? _cleanupTask;
+ private bool _disposed;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LimitedConcurrencyLibraryScheduler"/> class.
+ /// </summary>
+ /// <param name="hostApplicationLifetime">The hosting lifetime.</param>
+ /// <param name="logger">The logger.</param>
+ /// <param name="serverConfigurationManager">The server configuration manager.</param>
+ public LimitedConcurrencyLibraryScheduler(
+ IHostApplicationLifetime hostApplicationLifetime,
+ ILogger<LimitedConcurrencyLibraryScheduler> logger,
+ IServerConfigurationManager serverConfigurationManager)
+ {
+ _hostApplicationLifetime = hostApplicationLifetime;
+ _logger = logger;
+ _serverConfigurationManager = serverConfigurationManager;
+ }
+
+ private void ScheduleTaskCleanup()
+ {
+ lock (_taskLock)
+ {
+ if (_cleanupTask is not null)
+ {
+ _logger.LogDebug("Cleanup task already scheduled.");
+ // cleanup task is already running.
+ return;
+ }
+
+ _cleanupTask = RunCleanupTask();
+ }
+
+ async Task RunCleanupTask()
+ {
+ _logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod);
+ await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false);
+ if (_disposed)
+ {
+ _logger.LogDebug("Abort cleaning up, already disposed.");
+ return;
+ }
+
+ lock (_taskLock)
+ {
+ if (_tasks.Count > 0 || _workCounter > 0)
+ {
+ _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();
+ return;
+ }
+ }
+
+ _logger.LogDebug("Cleanup runners.");
+ foreach (var item in _taskRunners.ToArray())
+ {
+ await item.Key.CancelAsync().ConfigureAwait(false);
+ _taskRunners.Remove(item.Key);
+ }
+ }
+ }
+
+ private bool ShouldForceSequentialOperation()
+ {
+ // if the user either set the setting to 1 or it's unset and we have fewer than 4 cores it's better to run sequentially.
+ var fanoutSetting = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
+ return fanoutSetting == 1 || (fanoutSetting <= 0 && Environment.ProcessorCount <= 3);
+ }
+
+ private int CalculateScanConcurrencyLimit()
+ {
+ // when this is invoked, we already checked ShouldForceSequentialOperation for the sequential check.
+ var fanoutConcurrency = _serverConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
+ if (fanoutConcurrency <= 0)
+ {
+ // in case the user did not set a limit manually, we can assume he has 3 or more cores as already checked by ShouldForceSequentialOperation.
+ return Environment.ProcessorCount - 3;
+ }
+
+ return fanoutConcurrency;
+ }
+
+ private void Worker()
+ {
+ lock (_taskLock)
+ {
+ var operationFanout = Math.Max(0, CalculateScanConcurrencyLimit() - _taskRunners.Count);
+ _logger.LogDebug("Spawn {NumberRunners} new runners.", operationFanout);
+ for (int i = 0; i < operationFanout; i++)
+ {
+ var stopToken = new CancellationTokenSource();
+ var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping);
+ _taskRunners.Add(
+ combinedSource,
+ Task.Factory.StartNew(
+ ItemWorker,
+ (combinedSource, stopToken),
+ combinedSource.Token,
+ TaskCreationOptions.PreferFairness,
+ TaskScheduler.Default));
+ }
+ }
+ }
+
+ private async Task ItemWorker(object? obj)
+ {
+ var stopToken = ((CancellationTokenSource TaskStop, CancellationTokenSource GlobalStop))obj!;
+ _deadlockDetector.Value = stopToken.TaskStop;
+ try
+ {
+ foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token))
+ {
+ stopToken.GlobalStop.Token.ThrowIfCancellationRequested();
+ try
+ {
+ var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
+ Debug.Assert(newWorkerLimit, "_workCounter > 0");
+ _logger.LogDebug("Process new item '{Data}'.", item.Data);
+ await ProcessItem(item).ConfigureAwait(false);
+ }
+ finally
+ {
+ var newWorkerLimit = Interlocked.Decrement(ref _workCounter) >= 0;
+ Debug.Assert(newWorkerLimit, "_workCounter > 0");
+ }
+ }
+ }
+ catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested)
+ {
+ // thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose.
+ }
+ finally
+ {
+ _logger.LogDebug("Cleanup Runner'.");
+ _deadlockDetector.Value = default!;
+ _taskRunners.Remove(stopToken.TaskStop);
+ stopToken.GlobalStop.Dispose();
+ stopToken.TaskStop.Dispose();
+ }
+ }
+
+ private async Task ProcessItem(TaskQueueItem item)
+ {
+ try
+ {
+ if (item.CancellationToken.IsCancellationRequested)
+ {
+ // if item is cancelled, just skip it
+ return;
+ }
+
+ await item.Worker(item.Data).ConfigureAwait(true);
+ }
+ catch (System.Exception ex)
+ {
+ _logger.LogError(ex, "Error while performing a library operation");
+ }
+ finally
+ {
+ item.Progress.Report(100);
+ item.Done.SetResult();
+ }
+ }
+
+ /// <inheritdoc/>
+ public async Task Enqueue<T>(T[] data, Func<T, IProgress<double>, Task> worker, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (data.Length == 0 || cancellationToken.IsCancellationRequested)
+ {
+ progress.Report(100);
+ return;
+ }
+
+ _logger.LogDebug("Enqueue new Workset of {NoItems} items.", data.Length);
+
+ TaskQueueItem[] workItems = null!;
+
+ void UpdateProgress()
+ {
+ progress.Report(workItems.Select(e => e.ProgressValue).Average());
+ }
+
+ workItems = data.Select(item =>
+ {
+ TaskQueueItem queueItem = null!;
+ return queueItem = new TaskQueueItem()
+ {
+ Data = item!,
+ Progress = new Progress<double>(innerPercent =>
+ {
+ // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls
+ var innerPercentRounded = Math.Round(innerPercent);
+ if (queueItem.ProgressValue != innerPercentRounded)
+ {
+ queueItem.ProgressValue = innerPercentRounded;
+ UpdateProgress();
+ }
+ }),
+ Worker = (val) => worker((T)val, queueItem.Progress),
+ CancellationToken = cancellationToken
+ };
+ }).ToArray();
+
+ if (ShouldForceSequentialOperation())
+ {
+ _logger.LogDebug("Process sequentially.");
+ try
+ {
+ foreach (var item in workItems)
+ {
+ await ProcessItem(item).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // operation is cancelled. Do nothing.
+ }
+
+ _logger.LogDebug("Process sequentially done.");
+ return;
+ }
+
+ for (var i = 0; i < workItems.Length; i++)
+ {
+ var item = workItems[i]!;
+ _tasks.Add(item, CancellationToken.None);
+ }
+
+ if (_deadlockDetector.Value is not null)
+ {
+ _logger.LogDebug("Nested invocation detected, process in-place.");
+ try
+ {
+ // we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved
+ while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token))
+ {
+ await ProcessItem(item).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested)
+ {
+ // operation is cancelled. Do nothing.
+ }
+
+ _logger.LogDebug("process in-place done.");
+ }
+ else
+ {
+ Worker();
+ _logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
+ await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
+ _logger.LogDebug("{NoWorkers} completed.", workItems.Length);
+ ScheduleTaskCleanup();
+ }
+ }
+
+ /// <inheritdoc/>
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _tasks.CompleteAdding();
+ foreach (var item in _taskRunners)
+ {
+ await item.Key.CancelAsync().ConfigureAwait(false);
+ }
+
+ _tasks.Dispose();
+ if (_cleanupTask is not null)
+ {
+ await _cleanupTask.ConfigureAwait(false);
+ _cleanupTask?.Dispose();
+ }
+ }
+
+ private class TaskQueueItem
+ {
+ public required object Data { get; init; }
+
+ public double ProgressValue { get; set; }
+
+ public required Func<object, Task> Worker { get; init; }
+
+ public required IProgress<double> Progress { get; init; }
+
+ public TaskCompletionSource Done { get; } = new();
+
+ public CancellationToken CancellationToken { get; init; }
+ }
+}
diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
index 3353ad63f..b5d14e94b 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>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 7c3138002..a1d891535 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -230,10 +230,10 @@ namespace MediaBrowser.Controller.MediaEncoding
{
var hwType = encodingOptions.HardwareAccelerationType;
- // Only Intel has VA-API MJPEG encoder
+ // Only enable VA-API MJPEG encoder on Intel iHD driver.
+ // Legacy platforms supported ONLY by i965 do not support MJPEG encoder.
if (hwType == HardwareAccelerationType.vaapi
- && !(_mediaEncoder.IsVaapiDeviceInteliHD
- || _mediaEncoder.IsVaapiDeviceInteli965))
+ && !_mediaEncoder.IsVaapiDeviceInteliHD)
{
return _defaultMjpegEncoder;
}
@@ -1572,6 +1572,26 @@ namespace MediaBrowser.Controller.MediaEncoding
return FormattableString.Invariant($" -maxrate {bitrate} -bufsize {bufsize}");
}
+ if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoCodec, "av1_qsv", StringComparison.OrdinalIgnoreCase))
+ {
+ // TODO: probe QSV encoders' capabilities and enable more tuning options
+ // See also https://github.com/intel/media-delivery/blob/master/doc/quality.rst
+
+ // Enable MacroBlock level bitrate control for better subjective visual quality
+ var mbbrcOpt = string.Empty;
+ if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
+ {
+ mbbrcOpt = " -mbbrc 1";
+ }
+
+ // Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation
+ // Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes
+ return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {bitrate + 1} -rc_init_occupancy {bitrate * 2} -bufsize {bitrate * 4}");
+ }
+
if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase))
@@ -2356,6 +2376,13 @@ namespace MediaBrowser.Controller.MediaEncoding
var requestHasHDR10 = requestedRangeTypes.Contains(VideoRangeType.HDR10.ToString(), StringComparison.OrdinalIgnoreCase);
var requestHasHLG = requestedRangeTypes.Contains(VideoRangeType.HLG.ToString(), StringComparison.OrdinalIgnoreCase);
var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase);
+ var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase);
+
+ // If the client does not support DOVI and the video stream is DOVI without fallback, we should not copy it.
+ if (!requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVI)
+ {
+ return false;
+ }
if (!requestedRangeTypes.Contains(videoStream.VideoRangeType.ToString(), StringComparison.OrdinalIgnoreCase)
&& !((requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.DOVIWithHDR10)
@@ -2363,6 +2390,12 @@ namespace MediaBrowser.Controller.MediaEncoding
|| (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR)
|| (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus)))
{
+ // If the video stream is in HDR10+ or a static HDR format, don't allow copy if the client does not support HDR10 or HLG.
+ if (videoStream.VideoRangeType is VideoRangeType.HDR10Plus or VideoRangeType.HDR10 or VideoRangeType.HLG)
+ {
+ return false;
+ }
+
// Check complicated cases where we need to remove dynamic metadata
// Conservatively refuse to copy if the encoder can't remove dynamic metadata,
// but a removal is required for compatability reasons.
@@ -3471,6 +3504,21 @@ namespace MediaBrowser.Controller.MediaEncoding
doubleRateDeint ? "1" : "0");
}
+ if (hwDeintSuffix.Contains("opencl", StringComparison.OrdinalIgnoreCase))
+ {
+ var useBwdif = options.DeinterlaceMethod == DeinterlaceMethod.bwdif;
+
+ if (_mediaEncoder.SupportsFilter("yadif_opencl")
+ && _mediaEncoder.SupportsFilter("bwdif_opencl"))
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "{0}_opencl={1}:-1:0",
+ useBwdif ? "bwdif" : "yadif",
+ doubleRateDeint ? "1" : "0");
+ }
+ }
+
if (hwDeintSuffix.Contains("vaapi", StringComparison.OrdinalIgnoreCase))
{
return string.Format(
@@ -3947,6 +3995,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (hasSubs)
{
+ var alphaFormatOpt = string.Empty;
if (hasGraphicalSubs)
{
var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH);
@@ -3964,10 +4013,13 @@ namespace MediaBrowser.Controller.MediaEncoding
subFilters.Add(alphaSrcFilter);
subFilters.Add("format=yuva420p");
subFilters.Add(subTextSubtitlesFilter);
+
+ alphaFormatOpt = _mediaEncoder.SupportsFilterWithOption(FilterOptionType.OverlayCudaAlphaFormat)
+ ? ":alpha_format=premultiplied" : string.Empty;
}
subFilters.Add("hwupload=derive_device=cuda");
- overlayFilters.Add("overlay_cuda=eof_action=pass:repeatlast=0");
+ overlayFilters.Add($"overlay_cuda=eof_action=pass:repeatlast=0{alphaFormatOpt}");
}
}
else
@@ -4099,7 +4151,12 @@ namespace MediaBrowser.Controller.MediaEncoding
// map from d3d11va to opencl via d3d11-opencl interop.
mainFilters.Add("hwmap=derive_device=opencl:mode=read");
- // hw deint <= TODO: finish the 'yadif_opencl' filter
+ // hw deint
+ if (doDeintH2645)
+ {
+ var deintFilter = GetHwDeinterlaceFilter(state, options, "opencl");
+ mainFilters.Add(deintFilter);
+ }
// hw transpose
if (doOclTranspose)
@@ -4164,6 +4221,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (hasSubs)
{
+ var alphaFormatOpt = string.Empty;
if (hasGraphicalSubs)
{
var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH);
@@ -4181,10 +4239,13 @@ namespace MediaBrowser.Controller.MediaEncoding
subFilters.Add(alphaSrcFilter);
subFilters.Add("format=yuva420p");
subFilters.Add(subTextSubtitlesFilter);
+
+ alphaFormatOpt = _mediaEncoder.SupportsFilterWithOption(FilterOptionType.OverlayOpenclAlphaFormat)
+ ? ":alpha_format=premultiplied" : string.Empty;
}
subFilters.Add("hwupload=derive_device=opencl");
- overlayFilters.Add("overlay_opencl=eof_action=pass:repeatlast=0");
+ overlayFilters.Add($"overlay_opencl=eof_action=pass:repeatlast=0{alphaFormatOpt}");
overlayFilters.Add("hwmap=derive_device=d3d11va:mode=write:reverse=1");
overlayFilters.Add("format=d3d11");
}
@@ -4387,6 +4448,13 @@ namespace MediaBrowser.Controller.MediaEncoding
var swapOutputWandH = doVppTranspose && swapWAndH;
var hwScaleFilter = GetHwScaleFilter("vpp", "qsv", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
+ // d3d11va doesn't support dynamic pool size, use vpp filter ctx to relay
+ // to prevent encoder async and bframes from exhausting the decoder pool.
+ if (!string.IsNullOrEmpty(hwScaleFilter) && isD3d11vaDecoder)
+ {
+ hwScaleFilter += ":passthrough=0";
+ }
+
if (!string.IsNullOrEmpty(hwScaleFilter) && doVppTranspose)
{
hwScaleFilter += $":transpose={transposeDir}";
@@ -5892,7 +5960,7 @@ namespace MediaBrowser.Controller.MediaEncoding
// Use NV15 instead of P010 to avoid the issue.
// SDR inputs are using BGRA formats already which is not affected.
var intermediateFormat = string.Equals(outFormat, "p010", StringComparison.OrdinalIgnoreCase) ? "nv15" : outFormat;
- var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_divisible_by=4:afbc=1";
+ var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_original_aspect_ratio=increase:force_divisible_by=4:afbc=1";
mainFilters.Add(hwScaleFilterFirstPass);
}
@@ -5970,9 +6038,10 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (hasSubs)
{
+ var subMaxH = 1080;
if (hasGraphicalSubs)
{
- var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, reqMaxH);
+ var subPreProcFilters = GetGraphicalSubPreProcessFilters(swpInW, swpInH, subW, subH, reqW, reqH, reqMaxW, subMaxH);
subFilters.Add(subPreProcFilters);
subFilters.Add("format=bgra");
}
@@ -5982,7 +6051,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var subFramerate = hasAssSubs ? Math.Min(framerate ?? 25, 60) : 10;
// alphasrc=s=1280x720:r=10:start=0,format=bgra,subtitles,hwupload
- var alphaSrcFilter = GetAlphaSrcFilter(state, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH, subFramerate);
+ var alphaSrcFilter = GetAlphaSrcFilter(state, swpInW, swpInH, reqW, reqH, reqMaxW, subMaxH, subFramerate);
var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true);
subFilters.Add(alphaSrcFilter);
subFilters.Add("format=bgra");
@@ -5991,6 +6060,13 @@ namespace MediaBrowser.Controller.MediaEncoding
subFilters.Add("hwupload=derive_device=rkmpp");
+ // offload 1080p+ subtitles swscale upscaling from CPU to RGA
+ var (overlayW, overlayH) = GetFixedOutputSize(swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
+ if (overlayW.HasValue && overlayH.HasValue && overlayH.Value > subMaxH)
+ {
+ subFilters.Add($"vpp_rkrga=w={overlayW.Value}:h={overlayH.Value}:format=bgra:afbc=1");
+ }
+
// try enabling AFBC to save DDR bandwidth
var hwOverlayFilter = "overlay_rkrga=eof_action=pass:repeatlast=0:format=nv12";
if (isEncoderSupportAfbc)
@@ -6476,7 +6552,7 @@ namespace MediaBrowser.Controller.MediaEncoding
if (isD3d11Supported && isCodecAvailable)
{
return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11 -noautorotate" + stripRotationDataArgs : string.Empty)
- + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + (isAv1 ? " -c:v av1" : string.Empty);
+ + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + " -threads 2" + (isAv1 ? " -c:v av1" : string.Empty);
}
}
@@ -6947,7 +7023,8 @@ namespace MediaBrowser.Controller.MediaEncoding
if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))
{
- return GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
+ var accelType = GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
+ return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty);
}
}
@@ -7074,7 +7151,8 @@ namespace MediaBrowser.Controller.MediaEncoding
inputModifier += " -async " + state.InputAudioSync;
}
- if (!string.IsNullOrEmpty(state.InputVideoSync))
+ // The -fps_mode option cannot be applied to input
+ if (!string.IsNullOrEmpty(state.InputVideoSync) && _mediaEncoder.EncoderVersion < new Version(5, 1))
{
inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion);
}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index 8d6211051..43680f5c0 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
-using System.ComponentModel;
using System.Globalization;
using System.Linq;
using Jellyfin.Data.Enums;
@@ -22,8 +21,7 @@ namespace MediaBrowser.Controller.MediaEncoding
// For now, a common base class until the API and MediaEncoding classes are unified
public class EncodingJobInfo
{
- public int? OutputAudioBitrate;
- public int? OutputAudioChannels;
+ private static readonly char[] _separators = ['|', ','];
private TranscodeReason? _transcodeReasons = null;
@@ -36,6 +34,10 @@ namespace MediaBrowser.Controller.MediaEncoding
SupportedSubtitleCodecs = Array.Empty<string>();
}
+ public int? OutputAudioBitrate { get; set; }
+
+ public int? OutputAudioChannels { get; set; }
+
public TranscodeReason TranscodeReasons
{
get
@@ -586,7 +588,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (!string.IsNullOrEmpty(BaseRequest.Profile))
{
- return BaseRequest.Profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries);
+ return BaseRequest.Profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
if (!string.IsNullOrEmpty(codec))
@@ -595,7 +597,7 @@ namespace MediaBrowser.Controller.MediaEncoding
if (!string.IsNullOrEmpty(profile))
{
- return profile.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries);
+ return profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
}
@@ -606,7 +608,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (!string.IsNullOrEmpty(BaseRequest.VideoRangeType))
{
- return BaseRequest.VideoRangeType.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries);
+ return BaseRequest.VideoRangeType.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
if (!string.IsNullOrEmpty(codec))
@@ -615,7 +617,7 @@ namespace MediaBrowser.Controller.MediaEncoding
if (!string.IsNullOrEmpty(rangetype))
{
- return rangetype.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries);
+ return rangetype.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
}
@@ -626,7 +628,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (!string.IsNullOrEmpty(BaseRequest.CodecTag))
{
- return BaseRequest.CodecTag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries);
+ return BaseRequest.CodecTag.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
if (!string.IsNullOrEmpty(codec))
@@ -635,7 +637,7 @@ namespace MediaBrowser.Controller.MediaEncoding
if (!string.IsNullOrEmpty(codectag))
{
- return codectag.Split(new[] { '|', ',' }, StringSplitOptions.RemoveEmptyEntries);
+ return codectag.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
}
diff --git a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs
index a2b6e1d73..6ad953023 100644
--- a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs
+++ b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs
@@ -38,6 +38,16 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <summary>
/// The transpose_opencl_reversal.
/// </summary>
- TransposeOpenclReversal = 6
+ TransposeOpenclReversal = 6,
+
+ /// <summary>
+ /// The overlay_opencl_alpha_format.
+ /// </summary>
+ OverlayOpenclAlphaFormat = 7,
+
+ /// <summary>
+ /// The overlay_cuda_alpha_format.
+ /// </summary>
+ OverlayCudaAlphaFormat = 8
}
}
diff --git a/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs b/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs
deleted file mode 100644
index 8ce40a58d..000000000
--- a/MediaBrowser.Controller/MediaEncoding/IEncodingManager.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-#nullable disable
-
-#pragma warning disable CS1591
-
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-
-namespace MediaBrowser.Controller.MediaEncoding
-{
- public interface IEncodingManager
- {
- /// <summary>
- /// Refreshes the chapter images.
- /// </summary>
- /// <param name="video">Video to use.</param>
- /// <param name="directoryService">Directory service to use.</param>
- /// <param name="chapters">Set of chapters to refresh.</param>
- /// <param name="extractImages">Option to extract images.</param>
- /// <param name="saveChapters">Option to save chapters.</param>
- /// <param name="cancellationToken">CancellationToken to use for operation.</param>
- /// <returns><c>true</c> if successful, <c>false</c> if not.</returns>
- Task<bool> RefreshChapterImages(Video video, IDirectoryService directoryService, IReadOnlyList<ChapterInfo> chapters, bool extractImages, bool saveChapters, CancellationToken cancellationToken);
- }
-}
diff --git a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs
index 9bf27b3b2..bdd75da2f 100644
--- a/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs
+++ b/MediaBrowser.Controller/MediaEncoding/ISubtitleEncoder.cs
@@ -53,5 +53,13 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>System.String.</returns>
Task<string> GetSubtitleFilePath(MediaStream subtitleStream, MediaSourceInfo mediaSource, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Extracts all extractable subtitles (text and pgs).
+ /// </summary>
+ /// <param name="mediaSource">The mediaSource.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken);
}
}
diff --git a/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs b/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs
index 456977b88..4f13a7ecc 100644
--- a/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs
+++ b/MediaBrowser.Controller/MediaSegments/IMediaSegmentManager.cs
@@ -5,9 +5,10 @@ using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.MediaSegments;
-namespace MediaBrowser.Controller;
+namespace MediaBrowser.Controller.MediaSegments;
/// <summary>
/// Defines methods for interacting with media segments.
@@ -18,10 +19,11 @@ public interface IMediaSegmentManager
/// Uses all segment providers enabled for the <see cref="BaseItem"/>'s library to get the Media Segments.
/// </summary>
/// <param name="baseItem">The Item to evaluate.</param>
- /// <param name="overwrite">If set, will remove existing segments and replace it with new ones otherwise will check for existing segments and if found any, stops.</param>
- /// <param name="cancellationToken">stop request token.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <param name="forceOverwrite">If set, will force to remove existing segments and replace it with new ones otherwise will check for existing segments and if found any that should not be deleted, stops.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that indicates the Operation is finished.</returns>
- Task RunSegmentPluginProviders(BaseItem baseItem, bool overwrite, CancellationToken cancellationToken);
+ Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken);
/// <summary>
/// Returns if this item supports media segments.
@@ -46,22 +48,22 @@ public interface IMediaSegmentManager
Task DeleteSegmentAsync(Guid segmentId);
/// <summary>
- /// Obtains all segments associated with the itemId.
+ /// Deletes all media segments of an item.
/// </summary>
- /// <param name="itemId">The id of the <see cref="BaseItem"/>.</param>
- /// <param name="typeFilter">filters all media segments of the given type to be included. If null all types are included.</param>
- /// <param name="filterByProvider">When set filters the segments to only return those that which providers are currently enabled on their library.</param>
- /// <returns>An enumerator of <see cref="MediaSegmentDto"/>'s.</returns>
- Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(Guid itemId, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true);
+ /// <param name="itemId">The <see cref="BaseItem.Id"/> to delete all segments for.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>a task.</returns>
+ Task DeleteSegmentsAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
/// Obtains all segments associated with the itemId.
/// </summary>
/// <param name="item">The <see cref="BaseItem"/>.</param>
/// <param name="typeFilter">filters all media segments of the given type to be included. If null all types are included.</param>
+ /// <param name="libraryOptions">The library options.</param>
/// <param name="filterByProvider">When set filters the segments to only return those that which providers are currently enabled on their library.</param>
/// <returns>An enumerator of <see cref="MediaSegmentDto"/>'s.</returns>
- Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem item, IEnumerable<MediaSegmentType>? typeFilter, bool filterByProvider = true);
+ Task<IEnumerable<MediaSegmentDto>> GetSegmentsAsync(BaseItem item, IEnumerable<MediaSegmentType>? typeFilter, LibraryOptions libraryOptions, bool filterByProvider = true);
/// <summary>
/// Gets information about any media segments stored for the given itemId.
diff --git a/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs
index 39bb58bef..5a6d15d78 100644
--- a/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs
+++ b/MediaBrowser.Controller/MediaSegments/IMediaSegmentProvider.cs
@@ -1,13 +1,11 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model;
using MediaBrowser.Model.MediaSegments;
-namespace MediaBrowser.Controller;
+namespace MediaBrowser.Controller.MediaSegments;
/// <summary>
/// Provides methods for Obtaining the Media Segments from an Item.
diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
index 4757bfa30..1e0d77fe5 100644
--- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
+++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
@@ -81,6 +81,16 @@ namespace MediaBrowser.Controller.Net
protected abstract Task<TReturnDataType> GetDataToSend();
/// <summary>
+ /// Gets the data to send for a specific connection.
+ /// </summary>
+ /// <param name="connection">The connection.</param>
+ /// <returns>Task{`1}.</returns>
+ protected virtual Task<TReturnDataType> GetDataToSendForConnection(IWebSocketConnection connection)
+ {
+ return GetDataToSend();
+ }
+
+ /// <summary>
/// Processes the message.
/// </summary>
/// <param name="message">The message.</param>
@@ -174,17 +184,11 @@ namespace MediaBrowser.Controller.Net
continue;
}
- var data = await GetDataToSend().ConfigureAwait(false);
- if (data is null)
- {
- continue;
- }
-
IEnumerable<Task> GetTasks()
{
foreach (var tuple in tuples)
{
- yield return SendDataInternal(data, tuple);
+ yield return SendDataForConnectionAsync(tuple);
}
}
@@ -198,12 +202,19 @@ namespace MediaBrowser.Controller.Net
}
}
- private async Task SendDataInternal(TReturnDataType data, (IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State) tuple)
+ private async Task SendDataForConnectionAsync((IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State) tuple)
{
try
{
var (connection, cts, state) = tuple;
var cancellationToken = cts.Token;
+
+ var data = await GetDataToSendForConnection(connection).ConfigureAwait(false);
+ if (data is null)
+ {
+ return;
+ }
+
await connection.SendAsync(
new OutboundWebSocketMessage<TReturnDataType> { MessageType = Type, Data = data },
cancellationToken).ConfigureAwait(false);
diff --git a/MediaBrowser.Controller/Chapters/IChapterRepository.cs b/MediaBrowser.Controller/Persistence/IChapterRepository.cs
index e22cb0f58..64b90fd63 100644
--- a/MediaBrowser.Controller/Chapters/IChapterRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IChapterRepository.cs
@@ -1,36 +1,30 @@
using System;
using System.Collections.Generic;
-using MediaBrowser.Model.Dto;
+using System.Threading;
+using System.Threading.Tasks;
using MediaBrowser.Model.Entities;
-namespace MediaBrowser.Controller.Chapters;
+namespace MediaBrowser.Controller.Persistence;
/// <summary>
-/// Interface IChapterManager.
+/// Interface IChapterRepository.
/// </summary>
public interface IChapterRepository
{
/// <summary>
- /// Saves the chapters.
+ /// Deletes the chapters.
/// </summary>
/// <param name="itemId">The item.</param>
- /// <param name="chapters">The set of chapters.</param>
- void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters);
-
- /// <summary>
- /// Gets all chapters associated with the baseItem.
- /// </summary>
- /// <param name="baseItem">The baseitem.</param>
- /// <returns>A readonly list of chapter instances.</returns>
- IReadOnlyList<ChapterInfo> GetChapters(BaseItemDto baseItem);
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ Task DeleteChaptersAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
- /// Gets a single chapter of a BaseItem on a specific index.
+ /// Saves the chapters.
/// </summary>
- /// <param name="baseItem">The baseitem.</param>
- /// <param name="index">The index of that chapter.</param>
- /// <returns>A chapter instance.</returns>
- ChapterInfo? GetChapter(BaseItemDto baseItem, int index);
+ /// <param name="itemId">The item.</param>
+ /// <param name="chapters">The set of chapters.</param>
+ void SaveChapters(Guid itemId, IReadOnlyList<ChapterInfo> chapters);
/// <summary>
/// Gets all chapters associated with the baseItem.
diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs
index e185898bf..0026ab2b5 100644
--- a/MediaBrowser.Controller/Persistence/IItemRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs
@@ -5,8 +5,11 @@
using System;
using System.Collections.Generic;
using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Querying;
@@ -20,8 +23,8 @@ public interface IItemRepository
/// <summary>
/// Deletes the item.
/// </summary>
- /// <param name="id">The identifier.</param>
- void DeleteItem(Guid id);
+ /// <param name="ids">The identifier to delete.</param>
+ void DeleteItem(params IReadOnlyList<Guid> ids);
/// <summary>
/// Saves the items.
@@ -83,6 +86,8 @@ public interface IItemRepository
int GetCount(InternalItemsQuery filter);
+ ItemCounts GetItemCounts(InternalItemsQuery filter);
+
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetGenres(InternalItemsQuery filter);
QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetMusicGenres(InternalItemsQuery filter);
@@ -102,4 +107,27 @@ public interface IItemRepository
IReadOnlyList<string> GetGenreNames();
IReadOnlyList<string> GetAllArtistNames();
+
+ /// <summary>
+ /// Checks if an item has been persisted to the database.
+ /// </summary>
+ /// <param name="id">The id to check.</param>
+ /// <returns>True if the item exists, otherwise false.</returns>
+ Task<bool> ItemExistsAsync(Guid id);
+
+ /// <summary>
+ /// Gets a value indicating wherever all children of the requested Id has been played.
+ /// </summary>
+ /// <param name="user">The userdata to check against.</param>
+ /// <param name="id">The Top id to check.</param>
+ /// <param name="recursive">Whever the check should be done recursive. Warning expensive operation.</param>
+ /// <returns>A value indicating whever all children has been played.</returns>
+ bool GetIsPlayed(User user, Guid id, bool recursive);
+
+ /// <summary>
+ /// Gets all artist matches from the db.
+ /// </summary>
+ /// <param name="artistNames">The names of the artists.</param>
+ /// <returns>A map of the artist name and the potential matches.</returns>
+ IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames);
}
diff --git a/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs b/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs
index 4930434a7..2596784ba 100644
--- a/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IKeyframeRepository.cs
@@ -26,4 +26,12 @@ public interface IKeyframeRepository
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The task object representing the asynchronous operation.</returns>
Task SaveKeyframeDataAsync(Guid itemId, KeyframeData data, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Deletes the keyframe data.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The task object representing the asynchronous operation.</returns>
+ Task DeleteKeyframeDataAsync(Guid itemId, CancellationToken cancellationToken);
}
diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs
index 1062399e3..fc367b829 100644
--- a/MediaBrowser.Controller/Playlists/Playlist.cs
+++ b/MediaBrowser.Controller/Playlists/Playlist.cs
@@ -149,9 +149,11 @@ namespace MediaBrowser.Controller.Playlists
return [];
}
- public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query)
+ public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
{
- return GetPlayableItems(user, query);
+ var items = GetPlayableItems(user, query);
+ totalCount = items.Count;
+ return items;
}
public IReadOnlyList<Tuple<LinkedChild, BaseItem>> GetManageableItems()
diff --git a/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs b/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs
index 97f653edf..2206a021a 100644
--- a/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs
+++ b/MediaBrowser.Controller/Sorting/IUserBaseItemComparer.cs
@@ -26,6 +26,6 @@ namespace MediaBrowser.Controller.Sorting
/// Gets or sets the user data repository.
/// </summary>
/// <value>The user data repository.</value>
- IUserDataManager UserDataRepository { get; set; }
+ IUserDataManager UserDataManager { get; set; }
}
}
diff --git a/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs
new file mode 100644
index 000000000..b094ec275
--- /dev/null
+++ b/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs
@@ -0,0 +1,34 @@
+using System;
+
+namespace MediaBrowser.Controller.SystemBackupService;
+
+/// <summary>
+/// Manifest type for backups internal structure.
+/// </summary>
+public class BackupManifestDto
+{
+ /// <summary>
+ /// Gets or sets the jellyfin version this backup was created with.
+ /// </summary>
+ public required Version ServerVersion { get; set; }
+
+ /// <summary>
+ /// Gets or sets the backup engine version this backup was created with.
+ /// </summary>
+ public required Version BackupEngineVersion { get; set; }
+
+ /// <summary>
+ /// Gets or sets the date this backup was created with.
+ /// </summary>
+ public required DateTimeOffset DateCreated { get; set; }
+
+ /// <summary>
+ /// Gets or sets the path to the backup on the system.
+ /// </summary>
+ public required string Path { get; set; }
+
+ /// <summary>
+ /// Gets or sets the contents of the backup archive.
+ /// </summary>
+ public required BackupOptionsDto Options { get; set; }
+}
diff --git a/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs
new file mode 100644
index 000000000..fc5a109f1
--- /dev/null
+++ b/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs
@@ -0,0 +1,29 @@
+using System;
+
+namespace MediaBrowser.Controller.SystemBackupService;
+
+/// <summary>
+/// Defines the optional contents of the backup archive.
+/// </summary>
+public class BackupOptionsDto
+{
+ /// <summary>
+ /// Gets or sets a value indicating whether the archive contains the Metadata contents.
+ /// </summary>
+ public bool Metadata { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the archive contains the Trickplay contents.
+ /// </summary>
+ public bool Trickplay { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the archive contains the Subtitle contents.
+ /// </summary>
+ public bool Subtitles { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the archive contains the Database contents.
+ /// </summary>
+ public bool Database { get; set; } = true;
+}
diff --git a/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs
new file mode 100644
index 000000000..263fa00c8
--- /dev/null
+++ b/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs
@@ -0,0 +1,15 @@
+using System;
+using MediaBrowser.Common.Configuration;
+
+namespace MediaBrowser.Controller.SystemBackupService;
+
+/// <summary>
+/// Defines properties used to start a restore process.
+/// </summary>
+public class BackupRestoreRequestDto
+{
+ /// <summary>
+ /// Gets or Sets the name of the backup archive to restore from. Must be present in <see cref="IApplicationPaths.BackupPath"/>.
+ /// </summary>
+ public required string ArchiveFileName { get; set; }
+}
diff --git a/MediaBrowser.Controller/SystemBackupService/IBackupService.cs b/MediaBrowser.Controller/SystemBackupService/IBackupService.cs
new file mode 100644
index 000000000..0c586d811
--- /dev/null
+++ b/MediaBrowser.Controller/SystemBackupService/IBackupService.cs
@@ -0,0 +1,48 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.SystemBackupService;
+
+namespace Jellyfin.Server.Implementations.SystemBackupService;
+
+/// <summary>
+/// Defines an interface to restore and backup the jellyfin system.
+/// </summary>
+public interface IBackupService
+{
+ /// <summary>
+ /// Creates a new Backup zip file containing the current state of the application.
+ /// </summary>
+ /// <param name="backupOptions">The backup options.</param>
+ /// <returns>A task.</returns>
+ Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions);
+
+ /// <summary>
+ /// Gets a list of backups that are available to be restored from.
+ /// </summary>
+ /// <returns>A list of backup paths.</returns>
+ Task<BackupManifestDto[]> EnumerateBackups();
+
+ /// <summary>
+ /// Gets a single backup manifest if the path defines a valid Jellyfin backup archive.
+ /// </summary>
+ /// <param name="archivePath">The path to be loaded.</param>
+ /// <returns>The containing backup manifest or null if not existing or compatiable.</returns>
+ Task<BackupManifestDto?> GetBackupManifest(string archivePath);
+
+ /// <summary>
+ /// Restores an backup zip file created by jellyfin.
+ /// </summary>
+ /// <param name="archivePath">Path to the archive.</param>
+ /// <returns>A Task.</returns>
+ /// <exception cref="FileNotFoundException">Thrown when an invalid or missing file is specified.</exception>
+ /// <exception cref="NotSupportedException">Thrown when attempt to load an unsupported backup is made.</exception>
+ /// <exception cref="InvalidOperationException">Thrown for errors during the restore.</exception>
+ Task RestoreBackupAsync(string archivePath);
+
+ /// <summary>
+ /// Schedules a Restore and restarts the server.
+ /// </summary>
+ /// <param name="archivePath">The path to the archive to restore from.</param>
+ void ScheduleRestoreAndRestartServer(string archivePath);
+}
diff --git a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs
index 9ac8ead11..fba24329a 100644
--- a/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs
+++ b/MediaBrowser.Controller/Trickplay/ITrickplayManager.cs
@@ -21,7 +21,7 @@ public interface ITrickplayManager
/// <param name="libraryOptions">The library options.</param>
/// <param name="cancellationToken">CancellationToken to use for operation.</param>
/// <returns>Task.</returns>
- Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions? libraryOptions, CancellationToken cancellationToken);
+ Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken);
/// <summary>
/// Creates trickplay tiles out of individual thumbnails.
@@ -59,6 +59,14 @@ public interface ITrickplayManager
Task SaveTrickplayInfo(TrickplayInfo info);
/// <summary>
+ /// Deletes all trickplay info for an item.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ Task DeleteTrickplayDataAsync(Guid itemId, CancellationToken cancellationToken);
+
+ /// <summary>
/// Gets all trickplay infos for all media streams of an item.
/// </summary>
/// <param name="item">The item.</param>
@@ -93,7 +101,7 @@ public interface ITrickplayManager
/// <param name="libraryOptions">The library options.</param>
/// <param name="cancellationToken">CancellationToken to use for operation.</param>
/// <returns>Task.</returns>
- Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions? libraryOptions, CancellationToken cancellationToken);
+ Task MoveGeneratedTrickplayDataAsync(Video video, LibraryOptions libraryOptions, CancellationToken cancellationToken);
/// <summary>
/// Gets the trickplay HLS playlist.
diff --git a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
index 0bb341da1..f56bc71d0 100644
--- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
+++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
@@ -33,7 +33,8 @@ namespace MediaBrowser.LocalMetadata.Images
"poster",
"cover",
"jacket",
- "default"
+ "default",
+ "albumart"
};
private static readonly string[] _personImageFileNames =
diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs
index c25adb774..025a81524 100644
--- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs
+++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs
@@ -235,11 +235,11 @@ namespace MediaBrowser.LocalMetadata.Savers
{
if (item is Person)
{
- await writer.WriteElementStringAsync(null, "DeathDate", null, item.EndDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ await writer.WriteElementStringAsync(null, "DeathDate", null, item.EndDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false);
}
else if (item is not Episode)
{
- await writer.WriteElementStringAsync(null, "EndDate", null, item.EndDate.Value.ToLocalTime().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ await writer.WriteElementStringAsync(null, "EndDate", null, item.EndDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).ConfigureAwait(false);
}
}
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index 89291c73b..48a0654bb 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -133,19 +133,13 @@ namespace MediaBrowser.MediaEncoding.Attachments
var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
{
- if (!Directory.Exists(outputFolder))
+ var directory = Directory.CreateDirectory(outputFolder);
+ var fileNames = directory.GetFiles("*", SearchOption.TopDirectoryOnly).Select(f => f.Name).ToHashSet();
+ var missingFiles = mediaSource.MediaAttachments.Where(a => a.FileName is not null && !fileNames.Contains(a.FileName) && !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase));
+ if (!missingFiles.Any())
{
- Directory.CreateDirectory(outputFolder);
- }
- else
- {
- var fileNames = Directory.GetFiles(outputFolder, "*", SearchOption.TopDirectoryOnly).Select(f => Path.GetFileName(f));
- var missingFiles = mediaSource.MediaAttachments.Where(a => !fileNames.Contains(a.FileName) && !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase));
- if (!missingFiles.Any())
- {
- // Skip extraction if all files already exist
- return;
- }
+ // Skip extraction if all files already exist
+ return;
}
var processArgs = string.Format(
diff --git a/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs
index 2f158157e..19c1de9f7 100644
--- a/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs
+++ b/MediaBrowser.MediaEncoding/Configuration/EncodingConfigurationStore.cs
@@ -18,10 +18,16 @@ namespace MediaBrowser.MediaEncoding.Configuration
public void Validate(object oldConfig, object newConfig)
{
- var newPath = ((EncodingOptions)newConfig).TranscodingTempPath;
+ var oldEncodingOptions = (EncodingOptions)oldConfig;
+ var newEncodingOptions = (EncodingOptions)newConfig;
+
+ ArgumentNullException.ThrowIfNull(oldEncodingOptions, nameof(oldConfig));
+ ArgumentNullException.ThrowIfNull(newEncodingOptions, nameof(newConfig));
+
+ var newPath = newEncodingOptions.TranscodingTempPath;
if (!string.IsNullOrWhiteSpace(newPath)
- && !string.Equals(((EncodingOptions)oldConfig).TranscodingTempPath, newPath, StringComparison.Ordinal))
+ && !string.Equals(oldEncodingOptions.TranscodingTempPath, newPath, StringComparison.Ordinal))
{
// Validate
if (!Directory.Exists(newPath))
@@ -33,6 +39,12 @@ namespace MediaBrowser.MediaEncoding.Configuration
newPath));
}
}
+
+ if (!string.IsNullOrWhiteSpace(newEncodingOptions.EncoderAppPath)
+ && !string.Equals(oldEncodingOptions.EncoderAppPath, newEncodingOptions.EncoderAppPath, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException("Unable to update encoder app path.");
+ }
}
}
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index d28cd70ef..f4e8c39c1 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -14,8 +14,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
{
public partial class EncoderValidator
{
- private static readonly string[] _requiredDecoders = new[]
- {
+ private static readonly string[] _requiredDecoders =
+ [
"h264",
"hevc",
"vp8",
@@ -57,10 +57,10 @@ namespace MediaBrowser.MediaEncoding.Encoder
"vp8_rkmpp",
"vp9_rkmpp",
"av1_rkmpp"
- };
+ ];
- private static readonly string[] _requiredEncoders = new[]
- {
+ private static readonly string[] _requiredEncoders =
+ [
"libx264",
"libx265",
"libsvtav1",
@@ -97,10 +97,10 @@ namespace MediaBrowser.MediaEncoding.Encoder
"h264_rkmpp",
"hevc_rkmpp",
"mjpeg_rkmpp"
- };
+ ];
- private static readonly string[] _requiredFilters = new[]
- {
+ private static readonly string[] _requiredFilters =
+ [
// sw
"alphasrc",
"zscale",
@@ -123,6 +123,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
"tonemap_opencl",
"overlay_opencl",
"transpose_opencl",
+ "yadif_opencl",
+ "bwdif_opencl",
// vaapi
"scale_vaapi",
"deinterlace_vaapi",
@@ -148,17 +150,19 @@ namespace MediaBrowser.MediaEncoding.Encoder
"scale_rkrga",
"vpp_rkrga",
"overlay_rkrga"
- };
+ ];
- private static readonly Dictionary<int, string[]> _filterOptionsDict = new Dictionary<int, string[]>
+ private static readonly Dictionary<FilterOptionType, (string, string)> _filterOptionsDict = new Dictionary<FilterOptionType, (string, string)>
{
- { 0, new string[] { "scale_cuda", "format" } },
- { 1, new string[] { "tonemap_cuda", "GPU accelerated HDR to SDR tonemapping" } },
- { 2, new string[] { "tonemap_opencl", "bt2390" } },
- { 3, new string[] { "overlay_opencl", "Action to take when encountering EOF from secondary input" } },
- { 4, new string[] { "overlay_vaapi", "Action to take when encountering EOF from secondary input" } },
- { 5, new string[] { "overlay_vulkan", "Action to take when encountering EOF from secondary input" } },
- { 6, new string[] { "transpose_opencl", "rotate by half-turn" } }
+ { FilterOptionType.ScaleCudaFormat, ("scale_cuda", "format") },
+ { FilterOptionType.TonemapCudaName, ("tonemap_cuda", "GPU accelerated HDR to SDR tonemapping") },
+ { FilterOptionType.TonemapOpenclBt2390, ("tonemap_opencl", "bt2390") },
+ { FilterOptionType.OverlayOpenclFrameSync, ("overlay_opencl", "Action to take when encountering EOF from secondary input") },
+ { FilterOptionType.OverlayVaapiFrameSync, ("overlay_vaapi", "Action to take when encountering EOF from secondary input") },
+ { FilterOptionType.OverlayVulkanFrameSync, ("overlay_vulkan", "Action to take when encountering EOF from secondary input") },
+ { FilterOptionType.TransposeOpenclReversal, ("transpose_opencl", "rotate by half-turn") },
+ { FilterOptionType.OverlayOpenclAlphaFormat, ("overlay_opencl", "alpha_format") },
+ { FilterOptionType.OverlayCudaAlphaFormat, ("overlay_cuda", "alpha_format") }
};
private static readonly Dictionary<BitStreamFilterOptionType, (string, string)> _bsfOptionsDict = new Dictionary<BitStreamFilterOptionType, (string, string)>
@@ -294,7 +298,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
public IEnumerable<string> GetFilters() => GetFFmpegFilters();
- public IDictionary<int, bool> GetFiltersWithOption() => GetFFmpegFiltersWithOption();
+ public IDictionary<FilterOptionType, bool> GetFiltersWithOption() => _filterOptionsDict
+ .ToDictionary(item => item.Key, item => CheckFilterWithOption(item.Value.Item1, item.Value.Item2));
public IDictionary<BitStreamFilterOptionType, bool> GetBitStreamFiltersWithOption() => _bsfOptionsDict
.ToDictionary(item => item.Key, item => CheckBitStreamFilterWithOption(item.Value.Item1, item.Value.Item2));
@@ -471,10 +476,10 @@ namespace MediaBrowser.MediaEncoding.Encoder
if (string.IsNullOrWhiteSpace(output))
{
- return Enumerable.Empty<string>();
+ return [];
}
- var found = output.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Skip(1).Distinct().ToList();
+ var found = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Skip(1).Distinct().ToList();
_logger.LogInformation("Available hwaccel types: {Types}", found);
return found;
@@ -580,12 +585,12 @@ namespace MediaBrowser.MediaEncoding.Encoder
catch (Exception ex)
{
_logger.LogError(ex, "Error detecting available {Codec}", codecstr);
- return Enumerable.Empty<string>();
+ return [];
}
if (string.IsNullOrWhiteSpace(output))
{
- return Enumerable.Empty<string>();
+ return [];
}
var required = codec == Codec.Encoder ? _requiredEncoders : _requiredDecoders;
@@ -610,12 +615,12 @@ namespace MediaBrowser.MediaEncoding.Encoder
catch (Exception ex)
{
_logger.LogError(ex, "Error detecting available filters");
- return Enumerable.Empty<string>();
+ return [];
}
if (string.IsNullOrWhiteSpace(output))
{
- return Enumerable.Empty<string>();
+ return [];
}
var found = FilterRegex()
@@ -628,20 +633,6 @@ namespace MediaBrowser.MediaEncoding.Encoder
return found;
}
- private Dictionary<int, bool> GetFFmpegFiltersWithOption()
- {
- Dictionary<int, bool> dict = new Dictionary<int, bool>();
- for (int i = 0; i < _filterOptionsDict.Count; i++)
- {
- if (_filterOptionsDict.TryGetValue(i, out var val) && val.Length == 2)
- {
- dict.Add(i, CheckFilterWithOption(val[0], val[1]));
- }
- }
-
- return dict;
- }
-
private string GetProcessOutput(string path, string arguments, bool readStdErr, string? testKey)
{
var redirectStandardIn = !string.IsNullOrEmpty(testKey);
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 39c0bfed4..b7fef842b 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -72,7 +72,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
private List<string> _decoders = new List<string>();
private List<string> _hwaccels = new List<string>();
private List<string> _filters = new List<string>();
- private IDictionary<int, bool> _filtersWithOption = new Dictionary<int, bool>();
+ private IDictionary<FilterOptionType, bool> _filtersWithOption = new Dictionary<FilterOptionType, bool>();
private IDictionary<BitStreamFilterOptionType, bool> _bitStreamFiltersWithOption = new Dictionary<BitStreamFilterOptionType, bool>();
private bool _isPkeyPauseSupported = false;
@@ -341,7 +341,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
_filters = list.ToList();
}
- public void SetAvailableFiltersWithOption(IDictionary<int, bool> dict)
+ public void SetAvailableFiltersWithOption(IDictionary<FilterOptionType, bool> dict)
{
_filtersWithOption = dict;
}
@@ -383,12 +383,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
/// <inheritdoc />
public bool SupportsFilterWithOption(FilterOptionType option)
{
- if (_filtersWithOption.TryGetValue((int)option, out var val))
- {
- return val;
- }
-
- return false;
+ return _filtersWithOption.TryGetValue(option, out var val) && val;
}
public bool SupportsBitStreamFilterWithOption(BitStreamFilterOptionType option)
@@ -516,7 +511,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format"
: "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format";
- if (_proberSupportsFirstVideoFrame)
+ if (!isAudio && _proberSupportsFirstVideoFrame)
{
args += " -show_frames -only_first_vframe";
}
@@ -542,7 +537,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
EnableRaisingEvents = true
};
- _logger.LogInformation("Starting {ProcessFileName} with args {ProcessArgs}", _ffprobePath, args);
+ _logger.LogDebug("Starting {ProcessFileName} with args {ProcessArgs}", _ffprobePath, args);
var memoryStream = new MemoryStream();
await using (memoryStream.ConfigureAwait(false))
@@ -642,7 +637,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
catch (Exception ex)
{
- _logger.LogError(ex, "I-frame image extraction failed, will attempt standard way. Input: {Arguments}", inputArgument);
+ _logger.LogWarning(ex, "I-frame image extraction failed, will attempt standard way. Input: {Arguments}", inputArgument);
}
}
@@ -737,12 +732,12 @@ namespace MediaBrowser.MediaEncoding.Encoder
{
var peak = videoStream.VideoRangeType == VideoRangeType.DOVI ? "400" : "100";
enableHdrExtraction = true;
- filters.Add($"tonemapx=tonemap=bt2390:desat=0:peak={peak}:t=bt709:m=bt709:p=bt709:format=yuv420p");
+ filters.Add($"tonemapx=tonemap=bt2390:desat=0:peak={peak}:t=bt709:m=bt709:p=bt709:format=yuv420p:range=full");
}
else if (SupportsFilter("zscale") && videoStream.VideoRangeType != VideoRangeType.DOVI)
{
enableHdrExtraction = true;
- filters.Add("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0:peak=100,zscale=t=bt709:m=bt709,format=yuv420p");
+ filters.Add("zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0:peak=100,zscale=t=bt709:m=bt709:out_range=full,format=yuv420p");
}
}
@@ -756,7 +751,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
_threads,
vf,
isAudio ? string.Empty : GetImageResolutionParameter(),
- EncodingHelper.GetVideoSyncOption("0", EncoderVersion).Trim(), // passthrough timestamp
+ EncodingHelper.GetVideoSyncOption("-1", EncoderVersion), // auto decide fps mode
tempExtractPath);
if (offset.HasValue)
@@ -767,7 +762,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
// The mpegts demuxer cannot seek to keyframes, so we have to let the
// decoder discard non-keyframes, which may contain corrupted images.
var seekMpegTs = offset.HasValue && string.Equals("mpegts", container, StringComparison.OrdinalIgnoreCase);
- if ((useIFrame && useTradeoff) || seekMpegTs)
+ if (useIFrame && (useTradeoff || seekMpegTs))
{
args = "-skip_frame nokey " + args;
}
@@ -832,7 +827,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
/// <inheritdoc />
- public Task<string> ExtractVideoImagesOnIntervalAccelerated(
+ public async Task<string> ExtractVideoImagesOnIntervalAccelerated(
string inputFile,
string container,
MediaSourceInfo mediaSource,
@@ -923,18 +918,34 @@ namespace MediaBrowser.MediaEncoding.Encoder
inputArg = "-hwaccel_flags +low_priority " + inputArg;
}
- if (enableKeyFrameOnlyExtraction)
- {
- inputArg = "-skip_frame nokey " + inputArg;
- }
-
var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, vidEncoder).Trim();
if (string.IsNullOrWhiteSpace(filterParam))
{
throw new InvalidOperationException("EncodingHelper returned empty or invalid filter parameters.");
}
- return ExtractVideoImagesOnIntervalInternal(inputArg, filterParam, vidEncoder, threads, qualityScale, priority, cancellationToken);
+ try
+ {
+ return await ExtractVideoImagesOnIntervalInternal(
+ (enableKeyFrameOnlyExtraction ? "-skip_frame nokey " : string.Empty) + inputArg,
+ filterParam,
+ vidEncoder,
+ threads,
+ qualityScale,
+ priority,
+ cancellationToken).ConfigureAwait(false);
+ }
+ catch (FfmpegException ex)
+ {
+ if (!enableKeyFrameOnlyExtraction)
+ {
+ throw;
+ }
+
+ _logger.LogWarning(ex, "I-frame trickplay extraction failed, will attempt standard way. Input: {InputFile}", inputFile);
+ }
+
+ return await ExtractVideoImagesOnIntervalInternal(inputArg, filterParam, vidEncoder, threads, qualityScale, priority, cancellationToken).ConfigureAwait(false);
}
private async Task<string> ExtractVideoImagesOnIntervalInternal(
@@ -1076,11 +1087,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
}
- var exitCode = ranToCompletion ? processWrapper.ExitCode ?? 0 : -1;
-
- if (exitCode == -1)
+ if (!ranToCompletion || processWrapper.ExitCode != 0)
{
- _logger.LogError("ffmpeg image extraction failed for {ProcessDescription}", processDescription);
// Cleanup temp folder here, because the targetDirectory is not returned and the cleanup for failed ffmpeg process is not possible for caller.
// Ideally the ffmpeg should not write any files if it fails, but it seems like it is not guaranteed.
try
@@ -1115,6 +1123,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
{
process.Process.Start();
+ try
+ {
+ process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Unable to set process priority to BelowNormal for {ProcessFileName}", process.Process.StartInfo.FileName);
+ }
+
lock (_runningProcessesLock)
{
_runningProcesses.Add(process);
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 5784deacd..1823496bf 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -30,9 +30,11 @@ namespace MediaBrowser.MediaEncoding.Probing
private const string ArtistReplaceValue = " | ";
- private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' };
- private readonly string[] _webmVideoCodecs = { "av1", "vp8", "vp9" };
- private readonly string[] _webmAudioCodecs = { "opus", "vorbis" };
+ private static readonly char[] _basicDelimiters = ['/', ';'];
+ private static readonly char[] _nameDelimiters = [.. _basicDelimiters, '|', '\\'];
+ private static readonly char[] _genreDelimiters = [.. _basicDelimiters, ','];
+ private static readonly string[] _webmVideoCodecs = ["av1", "vp8", "vp9"];
+ private static readonly string[] _webmAudioCodecs = ["opus", "vorbis"];
private readonly ILogger _logger;
private readonly ILocalizationManager _localization;
@@ -152,11 +154,12 @@ namespace MediaBrowser.MediaEncoding.Probing
info.Name = tags.GetFirstNotNullNorWhiteSpaceValue("title", "title-eng");
info.ForcedSortName = tags.GetFirstNotNullNorWhiteSpaceValue("sort_name", "title-sort", "titlesort");
- info.Overview = tags.GetFirstNotNullNorWhiteSpaceValue("synopsis", "description", "desc");
+ info.Overview = tags.GetFirstNotNullNorWhiteSpaceValue("synopsis", "description", "desc", "comment");
- info.IndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "episode_sort");
info.ParentIndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "season_number");
- info.ShowName = tags.GetValueOrDefault("show_name");
+ info.IndexNumber = FFProbeHelpers.GetDictionaryNumericValue(tags, "episode_sort") ??
+ FFProbeHelpers.GetDictionaryNumericValue(tags, "episode_id");
+ info.ShowName = tags.GetValueOrDefault("show_name", "show");
info.ProductionYear = FFProbeHelpers.GetDictionaryNumericValue(tags, "date");
// Several different forms of retail/premiere date
@@ -174,7 +177,7 @@ namespace MediaBrowser.MediaEncoding.Probing
if (tags.TryGetValue("artists", out var artists) && !string.IsNullOrWhiteSpace(artists))
{
- info.Artists = SplitDistinctArtists(artists, new[] { '/', ';' }, false).ToArray();
+ info.Artists = SplitDistinctArtists(artists, _basicDelimiters, false).ToArray();
}
else
{
@@ -309,7 +312,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s)));
}
- private int? GetEstimatedAudioBitrate(string codec, int? channels)
+ private static int? GetEstimatedAudioBitrate(string codec, int? channels)
{
if (!channels.HasValue)
{
@@ -530,7 +533,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return pairs;
}
- private void ProcessPairs(string key, List<NameValuePair> pairs, MediaInfo info)
+ private static void ProcessPairs(string key, List<NameValuePair> pairs, MediaInfo info)
{
List<BaseItemPerson> peoples = new List<BaseItemPerson>();
var distinctPairs = pairs.Select(p => p.Value)
@@ -579,7 +582,7 @@ namespace MediaBrowser.MediaEncoding.Probing
info.People = peoples.ToArray();
}
- private NameValuePair GetNameValuePair(XmlReader reader)
+ private static NameValuePair GetNameValuePair(XmlReader reader)
{
string name = null;
string value = null;
@@ -624,7 +627,7 @@ namespace MediaBrowser.MediaEncoding.Probing
};
}
- private string NormalizeSubtitleCodec(string codec)
+ private static string NormalizeSubtitleCodec(string codec)
{
if (string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase))
{
@@ -850,12 +853,36 @@ namespace MediaBrowser.MediaEncoding.Probing
}
}
- // stream.IsAnamorphic = string.Equals(streamInfo.sample_aspect_ratio, "0:1", StringComparison.OrdinalIgnoreCase) ||
- // string.Equals(stream.AspectRatio, "2.35:1", StringComparison.OrdinalIgnoreCase) ||
- // string.Equals(stream.AspectRatio, "2.40:1", StringComparison.OrdinalIgnoreCase);
-
// http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe
- stream.IsAnamorphic = string.Equals(streamInfo.SampleAspectRatio, "0:1", StringComparison.OrdinalIgnoreCase);
+ if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
+ {
+ stream.IsAnamorphic = false;
+ }
+ else if (!string.Equals(streamInfo.SampleAspectRatio, "0:1", StringComparison.Ordinal))
+ {
+ stream.IsAnamorphic = true;
+ }
+ else if (string.Equals(streamInfo.DisplayAspectRatio, "0:1", StringComparison.Ordinal))
+ {
+ stream.IsAnamorphic = false;
+ }
+ else if (!string.Equals(
+ streamInfo.DisplayAspectRatio,
+ // Force GetAspectRatio() to derive ratio from Width/Height directly by using null DAR
+ GetAspectRatio(new MediaStreamInfo
+ {
+ Width = streamInfo.Width,
+ Height = streamInfo.Height,
+ DisplayAspectRatio = null
+ }),
+ StringComparison.Ordinal))
+ {
+ stream.IsAnamorphic = true;
+ }
+ else
+ {
+ stream.IsAnamorphic = false;
+ }
if (streamInfo.Refs > 0)
{
@@ -904,16 +931,23 @@ namespace MediaBrowser.MediaEncoding.Probing
{
stream.Rotation = data.Rotation;
}
+
+ // Parse video frame cropping metadata from side_data
+ // TODO: save them and make HW filters to apply them in HWA pipelines
+ else if (string.Equals(data.SideDataType, "Frame Cropping", StringComparison.OrdinalIgnoreCase))
+ {
+ // Streams containing artificially added frame cropping
+ // metadata should not be marked as anamorphic.
+ stream.IsAnamorphic = false;
+ }
}
}
var frameInfo = frameInfoList?.FirstOrDefault(i => i.StreamIndex == stream.Index);
- if (frameInfo?.SideDataList != null)
+ if (frameInfo?.SideDataList is not null
+ && frameInfo.SideDataList.Any(data => string.Equals(data.SideDataType, "HDR Dynamic Metadata SMPTE2094-40 (HDR10+)", StringComparison.OrdinalIgnoreCase)))
{
- if (frameInfo.SideDataList.Any(data => string.Equals(data.SideDataType, "HDR Dynamic Metadata SMPTE2094-40 (HDR10+)", StringComparison.OrdinalIgnoreCase)))
- {
- stream.Hdr10PlusPresentFlag = true;
- }
+ stream.Hdr10PlusPresentFlag = true;
}
}
else if (streamInfo.CodecType == CodecType.Data)
@@ -1000,7 +1034,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return stream;
}
- private void NormalizeStreamTitle(MediaStream stream)
+ private static void NormalizeStreamTitle(MediaStream stream)
{
if (string.Equals(stream.Title, "cc", StringComparison.OrdinalIgnoreCase)
|| stream.Type == MediaStreamType.EmbeddedImage)
@@ -1015,7 +1049,7 @@ namespace MediaBrowser.MediaEncoding.Probing
/// <param name="tags">The tags.</param>
/// <param name="key">The key.</param>
/// <returns>System.String.</returns>
- private string GetDictionaryValue(IReadOnlyDictionary<string, string> tags, string key)
+ private static string GetDictionaryValue(IReadOnlyDictionary<string, string> tags, string key)
{
if (tags is null)
{
@@ -1027,7 +1061,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return val;
}
- private string ParseChannelLayout(string input)
+ private static string ParseChannelLayout(string input)
{
if (string.IsNullOrEmpty(input))
{
@@ -1037,7 +1071,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return input.AsSpan().LeftPart('(').ToString();
}
- private string GetAspectRatio(MediaStreamInfo info)
+ private static string GetAspectRatio(MediaStreamInfo info)
{
var original = info.DisplayAspectRatio;
@@ -1106,7 +1140,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return original;
}
- private bool IsClose(double d1, double d2, double variance = .005)
+ private static bool IsClose(double d1, double d2, double variance = .005)
{
return Math.Abs(d1 - d2) <= variance;
}
@@ -1139,7 +1173,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return divisor == 0f ? null : dividend / divisor;
}
- private void SetAudioRuntimeTicks(InternalMediaInfoResult result, MediaInfo data)
+ private static void SetAudioRuntimeTicks(InternalMediaInfoResult result, MediaInfo data)
{
// Get the first info stream
var stream = result.Streams?.FirstOrDefault(s => s.CodecType == CodecType.Audio);
@@ -1164,7 +1198,7 @@ namespace MediaBrowser.MediaEncoding.Probing
}
}
- private int? GetBPSFromTags(MediaStreamInfo streamInfo)
+ private static int? GetBPSFromTags(MediaStreamInfo streamInfo)
{
if (streamInfo?.Tags is null)
{
@@ -1180,7 +1214,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return null;
}
- private double? GetRuntimeSecondsFromTags(MediaStreamInfo streamInfo)
+ private static double? GetRuntimeSecondsFromTags(MediaStreamInfo streamInfo)
{
if (streamInfo?.Tags is null)
{
@@ -1196,7 +1230,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return null;
}
- private long? GetNumberOfBytesFromTags(MediaStreamInfo streamInfo)
+ private static long? GetNumberOfBytesFromTags(MediaStreamInfo streamInfo)
{
if (streamInfo?.Tags is null)
{
@@ -1213,7 +1247,7 @@ namespace MediaBrowser.MediaEncoding.Probing
return null;
}
- private void SetSize(InternalMediaInfoResult data, MediaInfo info)
+ private static void SetSize(InternalMediaInfoResult data, MediaInfo info)
{
if (data.Format is null)
{
@@ -1358,7 +1392,7 @@ namespace MediaBrowser.MediaEncoding.Probing
audio.TrySetProviderId(MetadataProvider.MusicBrainzTrack, mb);
}
- private string GetMultipleMusicBrainzId(string value)
+ private static string GetMultipleMusicBrainzId(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
@@ -1530,7 +1564,7 @@ namespace MediaBrowser.MediaEncoding.Probing
if (tags.TryGetValue("WM/Genre", out var genres) && !string.IsNullOrWhiteSpace(genres))
{
- var genreList = genres.Split(new[] { ';', '/', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var genreList = genres.Split(_genreDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// If this is empty then don't overwrite genres that might have been fetched earlier
if (genreList.Length > 0)
@@ -1547,7 +1581,7 @@ namespace MediaBrowser.MediaEncoding.Probing
if (tags.TryGetValue("WM/MediaCredits", out var people) && !string.IsNullOrEmpty(people))
{
video.People = Array.ConvertAll(
- people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
+ people.Split(_basicDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
i => new BaseItemPerson { Name = i, Type = PersonKind.Actor });
}
@@ -1656,7 +1690,7 @@ namespace MediaBrowser.MediaEncoding.Probing
}
// REVIEW: find out why the byte array needs to be 197 bytes long and comment the reason
- private TransportStreamTimestamp GetMpegTimestamp(string path)
+ private static TransportStreamTimestamp GetMpegTimestamp(string path)
{
var packetBuffer = new byte[197];
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 777e33587..88a7bb4b4 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -169,16 +169,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
if (fileInfo.IsExternal)
{
- using (var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false))
+ var stream = await GetStream(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
{
- var result = CharsetDetector.DetectFromStream(stream).Detected;
+ var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
+ var detected = result.Detected;
stream.Position = 0;
- if (result is not null)
+ if (detected is not null)
{
- _logger.LogDebug("charset {CharSet} detected for {Path}", result.EncodingName, fileInfo.Path);
+ _logger.LogDebug("charset {CharSet} detected for {Path}", detected.EncodingName, fileInfo.Path);
- using var reader = new StreamReader(stream, result.Encoding);
+ using var reader = new StreamReader(stream, detected.Encoding);
var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
return new MemoryStream(Encoding.UTF8.GetBytes(text));
@@ -475,13 +477,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|| string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
}
- /// <summary>
- /// Extracts all extractable subtitles (text and pgs).
- /// </summary>
- /// <param name="mediaSource">The mediaSource.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- private async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken)
+ /// <inheritdoc />
+ public async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken)
{
var locks = new List<IDisposable>();
var extractableStreams = new List<MediaStream>();
@@ -489,10 +486,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles
try
{
var subtitleStreams = mediaSource.MediaStreams
- .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true, IsExternal: false });
+ .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true });
foreach (var subtitleStream in subtitleStreams)
{
+ if (subtitleStream.IsExternal && !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
@@ -510,6 +512,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (extractableStreams.Count > 0)
{
await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
+ await ExtractAllExtractableSubtitlesMKS(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
@@ -522,6 +525,72 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
+ private async Task ExtractAllExtractableSubtitlesMKS(
+ MediaSourceInfo mediaSource,
+ List<MediaStream> subtitleStreams,
+ CancellationToken cancellationToken)
+ {
+ var mksFiles = new List<string>();
+
+ foreach (var subtitleStream in subtitleStreams)
+ {
+ if (string.IsNullOrEmpty(subtitleStream.Path) || !subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ if (!mksFiles.Contains(subtitleStream.Path))
+ {
+ mksFiles.Add(subtitleStream.Path);
+ }
+ }
+
+ if (mksFiles.Count == 0)
+ {
+ return;
+ }
+
+ foreach (string mksFile in mksFiles)
+ {
+ var inputPath = _mediaEncoder.GetInputArgument(mksFile, mediaSource);
+ var outputPaths = new List<string>();
+ var args = string.Format(
+ CultureInfo.InvariantCulture,
+ "-i {0} -copyts",
+ inputPath);
+
+ foreach (var subtitleStream in subtitleStreams)
+ {
+ if (!subtitleStream.Path.Equals(mksFile, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
+ var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
+ var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
+
+ if (streamIndex == -1)
+ {
+ _logger.LogError("Cannot find subtitle stream index for {InputPath} ({Index}), skipping this stream", inputPath, subtitleStream.Index);
+ continue;
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new FileNotFoundException($"Calculated path ({outputPath}) is not valid."));
+
+ outputPaths.Add(outputPath);
+ args += string.Format(
+ CultureInfo.InvariantCulture,
+ " -map 0:{0} -an -vn -c:s {1} \"{2}\"",
+ streamIndex,
+ outputCodec,
+ outputPath);
+ }
+
+ await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
private async Task ExtractAllExtractableSubtitlesInternal(
MediaSourceInfo mediaSource,
List<MediaStream> subtitleStreams,
@@ -536,6 +605,12 @@ namespace MediaBrowser.MediaEncoding.Subtitles
foreach (var subtitleStream in subtitleStreams)
{
+ if (!string.IsNullOrEmpty(subtitleStream.Path) && subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogDebug("Subtitle {Index} for file {InputPath} is part in an MKS file. Skipping", inputPath, subtitleStream.Index);
+ continue;
+ }
+
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
@@ -557,6 +632,20 @@ namespace MediaBrowser.MediaEncoding.Subtitles
outputPath);
}
+ if (outputPaths.Count == 0)
+ {
+ return;
+ }
+
+ await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ExtractSubtitlesForFile(
+ string inputPath,
+ string args,
+ List<string> outputPaths,
+ CancellationToken cancellationToken)
+ {
int exitCode;
using (var process = new Process
@@ -844,9 +933,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
.ConfigureAwait(false);
}
- using (var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false))
+ var stream = await GetStream(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
+ await using (stream.ConfigureAwait(false))
{
- var charset = CharsetDetector.DetectFromStream(stream).Detected?.EncodingName ?? string.Empty;
+ var result = await CharsetDetector.DetectFromStreamAsync(stream, cancellationToken).ConfigureAwait(false);
+ var charset = result.Detected?.EncodingName ?? string.Empty;
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
if ((path.EndsWith(".ass", StringComparison.Ordinal) || path.EndsWith(".ssa", StringComparison.Ordinal) || path.EndsWith(".srt", StringComparison.Ordinal))
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index 0cda803d6..2fd054f11 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -396,7 +396,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath);
// If subtitles get burned in fonts may need to be extracted from the media file
- if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode)
+ if (state.SubtitleStream is not null && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode || state.BaseRequest.AlwaysBurnInSubtitleWhenTranscoding))
{
if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
{
diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
index a58c01c96..ac5c12304 100644
--- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs
+++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
@@ -287,5 +287,5 @@ public class ServerConfiguration : BaseApplicationConfiguration
/// <summary>
/// Gets or sets a value indicating whether old authorization methods are allowed.
/// </summary>
- public bool EnableLegacyAuthorization { get; set; } = true;
+ public bool EnableLegacyAuthorization { get; set; }
}
diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs
index 1a636b240..88c378d66 100644
--- a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs
+++ b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs
@@ -43,7 +43,12 @@ namespace MediaBrowser.Model.Dlna
}
}
- var referenceBitrate = h264EquivalentOutputBitrate * (30.0f / (targetFps ?? 30.0f));
+ // Our reference bitrate is based on SDR h264 at 30fps
+ var referenceFps = targetFps ?? 30.0f;
+ var referenceScale = referenceFps <= 30.0f
+ ? 30.0f / referenceFps
+ : 1.0f / MathF.Sqrt(referenceFps / 30.0f);
+ var referenceBitrate = h264EquivalentOutputBitrate * referenceScale;
if (isHdr)
{
diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs
index 937409111..8f223c12a 100644
--- a/MediaBrowser.Model/Dto/BaseItemDto.cs
+++ b/MediaBrowser.Model/Dto/BaseItemDto.cs
@@ -569,7 +569,7 @@ namespace MediaBrowser.Model.Dto
/// Gets or sets the trickplay manifest.
/// </summary>
/// <value>The trickplay manifest.</value>
- public Dictionary<string, Dictionary<int, TrickplayInfo>> Trickplay { get; set; }
+ public Dictionary<string, Dictionary<int, TrickplayInfoDto>> Trickplay { get; set; }
/// <summary>
/// Gets or sets the type of the location.
diff --git a/MediaBrowser.Model/Dto/ItemCounts.cs b/MediaBrowser.Model/Dto/ItemCounts.cs
index 95f4a3d77..a15a0c82a 100644
--- a/MediaBrowser.Model/Dto/ItemCounts.cs
+++ b/MediaBrowser.Model/Dto/ItemCounts.cs
@@ -76,5 +76,14 @@ namespace MediaBrowser.Model.Dto
/// </summary>
/// <value>The item count.</value>
public int ItemCount { get; set; }
+
+ /// <summary>
+ /// Adds all counts.
+ /// </summary>
+ /// <returns>The total of the counts.</returns>
+ public int TotalItemCount()
+ {
+ return MovieCount + SeriesCount + EpisodeCount + ArtistCount + ProgramCount + TrailerCount + SongCount + AlbumCount + MusicVideoCount + BoxSetCount + BookCount;
+ }
}
}
diff --git a/MediaBrowser.Model/Dto/TrickplayInfoDto.cs b/MediaBrowser.Model/Dto/TrickplayInfoDto.cs
new file mode 100644
index 000000000..0c5f6e817
--- /dev/null
+++ b/MediaBrowser.Model/Dto/TrickplayInfoDto.cs
@@ -0,0 +1,62 @@
+using System;
+using Jellyfin.Database.Implementations.Entities;
+
+namespace MediaBrowser.Model.Dto;
+
+/// <summary>
+/// The trickplay api model.
+/// </summary>
+public record TrickplayInfoDto
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="TrickplayInfoDto"/> class.
+ /// </summary>
+ /// <param name="info">The trickplay info.</param>
+ public TrickplayInfoDto(TrickplayInfo info)
+ {
+ ArgumentNullException.ThrowIfNull(info);
+
+ Width = info.Width;
+ Height = info.Height;
+ TileWidth = info.TileWidth;
+ TileHeight = info.TileHeight;
+ ThumbnailCount = info.ThumbnailCount;
+ Interval = info.Interval;
+ Bandwidth = info.Bandwidth;
+ }
+
+ /// <summary>
+ /// Gets the width of an individual thumbnail.
+ /// </summary>
+ public int Width { get; init; }
+
+ /// <summary>
+ /// Gets the height of an individual thumbnail.
+ /// </summary>
+ public int Height { get; init; }
+
+ /// <summary>
+ /// Gets the amount of thumbnails per row.
+ /// </summary>
+ public int TileWidth { get; init; }
+
+ /// <summary>
+ /// Gets the amount of thumbnails per column.
+ /// </summary>
+ public int TileHeight { get; init; }
+
+ /// <summary>
+ /// Gets the total amount of non-black thumbnails.
+ /// </summary>
+ public int ThumbnailCount { get; init; }
+
+ /// <summary>
+ /// Gets the interval in milliseconds between each trickplay thumbnail.
+ /// </summary>
+ public int Interval { get; init; }
+
+ /// <summary>
+ /// Gets the peak bandwidth usage in bits per second.
+ /// </summary>
+ public int Bandwidth { get; init; }
+}
diff --git a/MediaBrowser.Model/Dto/UserDto.cs b/MediaBrowser.Model/Dto/UserDto.cs
index 05019741e..c6b4a4d14 100644
--- a/MediaBrowser.Model/Dto/UserDto.cs
+++ b/MediaBrowser.Model/Dto/UserDto.cs
@@ -1,5 +1,6 @@
#nullable disable
using System;
+using System.ComponentModel;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Users;
@@ -54,20 +55,22 @@ namespace MediaBrowser.Model.Dto
/// Gets or sets a value indicating whether this instance has password.
/// </summary>
/// <value><c>true</c> if this instance has password; otherwise, <c>false</c>.</value>
- public bool HasPassword { get; set; }
+ [Obsolete("This information is no longer provided")]
+ public bool? HasPassword { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether this instance has configured password.
/// </summary>
/// <value><c>true</c> if this instance has configured password; otherwise, <c>false</c>.</value>
- public bool HasConfiguredPassword { get; set; }
+ [Obsolete("This is always true")]
+ public bool? HasConfiguredPassword { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether this instance has configured easy password.
/// </summary>
/// <value><c>true</c> if this instance has configured easy password; otherwise, <c>false</c>.</value>
[Obsolete("Easy Password has been replaced with Quick Connect")]
- public bool HasConfiguredEasyPassword { get; set; }
+ public bool? HasConfiguredEasyPassword { get; set; } = false;
/// <summary>
/// Gets or sets whether async login is enabled or not.
diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs
index 5c8f37fcd..b1626e2c9 100644
--- a/MediaBrowser.Model/Entities/MediaStream.cs
+++ b/MediaBrowser.Model/Entities/MediaStream.cs
@@ -273,11 +273,28 @@ namespace MediaBrowser.Model.Entities
// Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded).
if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase))
{
- // Get full language string i.e. eng -> English.
- string fullLanguage = CultureInfo
- .GetCultures(CultureTypes.NeutralCultures)
- .FirstOrDefault(r => r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase))
- ?.DisplayName;
+ // Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified).
+ var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
+ CultureInfo match = null;
+ if (Language.Contains('-', StringComparison.OrdinalIgnoreCase))
+ {
+ match = cultures.FirstOrDefault(r =>
+ r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase));
+
+ if (match is null)
+ {
+ string baseLang = Language.AsSpan().LeftPart('-').ToString();
+ match = cultures.FirstOrDefault(r =>
+ r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+ else
+ {
+ match = cultures.FirstOrDefault(r =>
+ r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase));
+ }
+
+ string fullLanguage = match?.DisplayName;
attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language));
}
@@ -376,11 +393,28 @@ namespace MediaBrowser.Model.Entities
if (!string.IsNullOrEmpty(Language))
{
- // Get full language string i.e. eng -> English.
- string fullLanguage = CultureInfo
- .GetCultures(CultureTypes.NeutralCultures)
- .FirstOrDefault(r => r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase))
- ?.DisplayName;
+ // Get full language string i.e. eng -> English, zh-Hans -> Chinese (Simplified).
+ var cultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
+ CultureInfo match = null;
+ if (Language.Contains('-', StringComparison.OrdinalIgnoreCase))
+ {
+ match = cultures.FirstOrDefault(r =>
+ r.Name.Equals(Language, StringComparison.OrdinalIgnoreCase));
+
+ if (match is null)
+ {
+ string baseLang = Language.AsSpan().LeftPart('-').ToString();
+ match = cultures.FirstOrDefault(r =>
+ r.TwoLetterISOLanguageName.Equals(baseLang, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+ else
+ {
+ match = cultures.FirstOrDefault(r =>
+ r.ThreeLetterISOLanguageName.Equals(Language, StringComparison.OrdinalIgnoreCase));
+ }
+
+ string fullLanguage = match?.DisplayName;
attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language));
}
else
diff --git a/MediaBrowser.Model/Extensions/EnumerableExtensions.cs b/MediaBrowser.Model/Extensions/EnumerableExtensions.cs
index 8963bdb73..94f425229 100644
--- a/MediaBrowser.Model/Extensions/EnumerableExtensions.cs
+++ b/MediaBrowser.Model/Extensions/EnumerableExtensions.cs
@@ -50,7 +50,7 @@ namespace MediaBrowser.Model.Extensions
return 0;
})
- .ThenByDescending(i => i.CommunityRating ?? 0)
+ .ThenByDescending(i => Math.Round(i.CommunityRating ?? 0, 1) )
.ThenByDescending(i => i.VoteCount ?? 0);
}
}
diff --git a/MediaBrowser.Model/IO/AsyncFile.cs b/MediaBrowser.Model/IO/AsyncFile.cs
index 3c8007d1c..a9db6b81c 100644
--- a/MediaBrowser.Model/IO/AsyncFile.cs
+++ b/MediaBrowser.Model/IO/AsyncFile.cs
@@ -27,6 +27,14 @@ namespace MediaBrowser.Model.IO
};
/// <summary>
+ /// Creates, or truncates and overwrites, a file in the specified path.
+ /// </summary>
+ /// <param name="path">The path and name of the file to create.</param>
+ /// <returns>A <see cref="FileStream" /> that provides read/write access to the file specified in path.</returns>
+ public static FileStream Create(string path)
+ => new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous);
+
+ /// <summary>
/// Opens an existing file for reading.
/// </summary>
/// <param name="path">The file to be opened for reading.</param>
diff --git a/MediaBrowser.Model/Lyrics/LyricLineCue.cs b/MediaBrowser.Model/Lyrics/LyricLineCue.cs
index 1172a0231..291553361 100644
--- a/MediaBrowser.Model/Lyrics/LyricLineCue.cs
+++ b/MediaBrowser.Model/Lyrics/LyricLineCue.cs
@@ -8,22 +8,29 @@ public class LyricLineCue
/// <summary>
/// Initializes a new instance of the <see cref="LyricLineCue"/> class.
/// </summary>
- /// <param name="position">The start of the character index of the lyric.</param>
+ /// <param name="position">The start character index of the cue.</param>
+ /// <param name="endPosition">The end character index of the cue.</param>
/// <param name="start">The start of the timestamp the lyric is synced to in ticks.</param>
/// <param name="end">The end of the timestamp the lyric is synced to in ticks.</param>
- public LyricLineCue(int position, long start, long? end)
+ public LyricLineCue(int position, int endPosition, long start, long? end)
{
Position = position;
+ EndPosition = endPosition;
Start = start;
End = end;
}
/// <summary>
- /// Gets the character index of the lyric.
+ /// Gets the start character index of the cue.
/// </summary>
public int Position { get; }
/// <summary>
+ /// Gets the end character index of the cue.
+ /// </summary>
+ public int EndPosition { get; }
+
+ /// <summary>
/// Gets the timestamp the lyric is synced to in ticks.
/// </summary>
public long Start { get; }
diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj
index e9dab6bc8..ef025d02d 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>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs b/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs
index 8c1f44de8..53d017375 100644
--- a/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs
+++ b/MediaBrowser.Model/MediaSegments/MediaSegmentGenerationRequest.cs
@@ -1,4 +1,7 @@
using System;
+using System.Collections.Generic;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Model.MediaSegments;
namespace MediaBrowser.Model;
@@ -11,4 +14,9 @@ public record MediaSegmentGenerationRequest
/// Gets the Id to the BaseItem the segments should be extracted from.
/// </summary>
public Guid ItemId { get; init; }
+
+ /// <summary>
+ /// Gets existing media segments generated on an earlier scan by this provider.
+ /// </summary>
+ public required IReadOnlyList<MediaSegmentDto> ExistingSegments { get; init; }
}
diff --git a/MediaBrowser.Model/Net/MimeTypes.cs b/MediaBrowser.Model/Net/MimeTypes.cs
index de087d0e7..79f8675cb 100644
--- a/MediaBrowser.Model/Net/MimeTypes.cs
+++ b/MediaBrowser.Model/Net/MimeTypes.cs
@@ -56,6 +56,7 @@ namespace MediaBrowser.Model.Net
".rec",
".ts",
".rmvb",
+ ".vob",
".webm",
".wmv",
".wtv",
diff --git a/MediaBrowser.Model/Session/QueueItem.cs b/MediaBrowser.Model/Session/QueueItem.cs
index 32b19101b..43920a846 100644
--- a/MediaBrowser.Model/Session/QueueItem.cs
+++ b/MediaBrowser.Model/Session/QueueItem.cs
@@ -3,12 +3,11 @@
using System;
-namespace MediaBrowser.Model.Session
+namespace MediaBrowser.Model.Session;
+
+public record QueueItem
{
- public class QueueItem
- {
- public Guid Id { get; set; }
+ public Guid Id { get; set; }
- public string PlaylistItemId { get; set; }
- }
+ public string PlaylistItemId { get; set; }
}
diff --git a/MediaBrowser.Model/Users/ForgotPasswordAction.cs b/MediaBrowser.Model/Users/ForgotPasswordAction.cs
index f198476e3..55907e6c8 100644
--- a/MediaBrowser.Model/Users/ForgotPasswordAction.cs
+++ b/MediaBrowser.Model/Users/ForgotPasswordAction.cs
@@ -1,11 +1,15 @@
#pragma warning disable CS1591
+using System;
+
namespace MediaBrowser.Model.Users
{
public enum ForgotPasswordAction
{
+ [Obsolete("Returning different actions represents a security concern.")]
ContactAdmin = 0,
PinCode = 1,
+ [Obsolete("Returning different actions represents a security concern.")]
InNetworkRequired = 2
}
}
diff --git a/MediaBrowser.Providers/Books/AudioBookMetadataService.cs b/MediaBrowser.Providers/Books/AudioBookMetadataService.cs
index 96e1165b6..845f53493 100644
--- a/MediaBrowser.Providers/Books/AudioBookMetadataService.cs
+++ b/MediaBrowser.Providers/Books/AudioBookMetadataService.cs
@@ -1,50 +1,64 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Books
+namespace MediaBrowser.Providers.Books;
+
+/// <summary>
+/// Service to manage audiobook metadata.
+/// </summary>
+public class AudioBookMetadataService : MetadataService<AudioBook, SongInfo>
{
- public class AudioBookMetadataService : MetadataService<AudioBook, SongInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AudioBookMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public AudioBookMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<AudioBookMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public AudioBookMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<AudioBookMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
+ }
- /// <inheritdoc />
- protected override void MergeData(
- MetadataResult<AudioBook> source,
- MetadataResult<AudioBook> target,
- MetadataField[] lockedFields,
- bool replaceData,
- bool mergeMetadataSettings)
- {
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ /// <inheritdoc />
+ protected override void MergeData(
+ MetadataResult<AudioBook> source,
+ MetadataResult<AudioBook> target,
+ MetadataField[] lockedFields,
+ bool replaceData,
+ bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
- var sourceItem = source.Item;
- var targetItem = target.Item;
+ var sourceItem = source.Item;
+ var targetItem = target.Item;
- if (replaceData || targetItem.Artists.Count == 0)
- {
- targetItem.Artists = sourceItem.Artists;
- }
+ if (replaceData || targetItem.Artists.Count == 0)
+ {
+ targetItem.Artists = sourceItem.Artists;
+ }
- if (replaceData || string.IsNullOrEmpty(targetItem.Album))
- {
- targetItem.Album = sourceItem.Album;
- }
+ if (replaceData || string.IsNullOrEmpty(targetItem.Album))
+ {
+ targetItem.Album = sourceItem.Album;
}
}
}
diff --git a/MediaBrowser.Providers/Books/BookMetadataService.cs b/MediaBrowser.Providers/Books/BookMetadataService.cs
index 50b9922c6..3b8dbfa7b 100644
--- a/MediaBrowser.Providers/Books/BookMetadataService.cs
+++ b/MediaBrowser.Providers/Books/BookMetadataService.cs
@@ -1,37 +1,51 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Books
+namespace MediaBrowser.Providers.Books;
+
+/// <summary>
+/// Service to manage book metadata.
+/// </summary>
+public class BookMetadataService : MetadataService<Book, BookInfo>
{
- public class BookMetadataService : MetadataService<Book, BookInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BookMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public BookMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<BookMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public BookMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<BookMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
+ }
- /// <inheritdoc />
- protected override void MergeData(MetadataResult<Book> source, MetadataResult<Book> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
- {
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ /// <inheritdoc />
+ protected override void MergeData(MetadataResult<Book> source, MetadataResult<Book> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
- if (replaceData || string.IsNullOrEmpty(target.Item.SeriesName))
- {
- target.Item.SeriesName = source.Item.SeriesName;
- }
+ if (replaceData || string.IsNullOrEmpty(target.Item.SeriesName))
+ {
+ target.Item.SeriesName = source.Item.SeriesName;
}
}
}
diff --git a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs
index b51ab4c08..eccf8a606 100644
--- a/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs
+++ b/MediaBrowser.Providers/BoxSets/BoxSetMetadataService.cs
@@ -1,85 +1,93 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.BoxSets
+namespace MediaBrowser.Providers.BoxSets;
+
+/// <summary>
+/// Service to manage boxset metadata.
+/// </summary>
+public class BoxSetMetadataService : MetadataService<BoxSet, BoxSetInfo>
{
- public class BoxSetMetadataService : MetadataService<BoxSet, BoxSetInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BoxSetMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public BoxSetMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<BoxSetMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public BoxSetMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<BoxSetMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
-
- /// <inheritdoc />
- protected override bool EnableUpdatingGenresFromChildren => true;
+ }
- /// <inheritdoc />
- protected override bool EnableUpdatingOfficialRatingFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingGenresFromChildren => true;
- /// <inheritdoc />
- protected override bool EnableUpdatingStudiosFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingOfficialRatingFromChildren => true;
- /// <inheritdoc />
- protected override bool EnableUpdatingPremiereDateFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingStudiosFromChildren => true;
- /// <inheritdoc />
- protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(BoxSet item)
- {
- return item.GetLinkedChildren();
- }
+ /// <inheritdoc />
+ protected override bool EnableUpdatingPremiereDateFromChildren => true;
- /// <inheritdoc />
- protected override void MergeData(MetadataResult<BoxSet> source, MetadataResult<BoxSet> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
- {
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ /// <inheritdoc />
+ protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(BoxSet item)
+ {
+ return item.GetLinkedChildren();
+ }
- var sourceItem = source.Item;
- var targetItem = target.Item;
+ /// <inheritdoc />
+ protected override void MergeData(MetadataResult<BoxSet> source, MetadataResult<BoxSet> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
- if (mergeMetadataSettings)
- {
- if (replaceData || targetItem.LinkedChildren.Length == 0)
- {
- targetItem.LinkedChildren = sourceItem.LinkedChildren;
- }
- else
- {
- targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
- }
- }
- }
+ var sourceItem = source.Item;
+ var targetItem = target.Item;
- /// <inheritdoc />
- protected override ItemUpdateType BeforeSaveInternal(BoxSet item, bool isFullRefresh, ItemUpdateType updateType)
+ if (mergeMetadataSettings)
{
- var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
+ // TODO: Change to only replace when currently empty or requested. This is currently not done because the metadata service is not handling attaching collection items based on the provider responses
+ targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
+ }
+ }
- var libraryFolderIds = item.GetLibraryFolderIds();
+ /// <inheritdoc />
+ protected override ItemUpdateType BeforeSaveInternal(BoxSet item, bool isFullRefresh, ItemUpdateType updateType)
+ {
+ var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
- var itemLibraryFolderIds = item.LibraryFolderIds;
- if (itemLibraryFolderIds is null || !libraryFolderIds.SequenceEqual(itemLibraryFolderIds))
- {
- item.LibraryFolderIds = libraryFolderIds;
- updatedType |= ItemUpdateType.MetadataImport;
- }
+ var libraryFolderIds = item.GetLibraryFolderIds();
- return updatedType;
+ var itemLibraryFolderIds = item.LibraryFolderIds;
+ if (itemLibraryFolderIds is null || !libraryFolderIds.SequenceEqual(itemLibraryFolderIds))
+ {
+ item.LibraryFolderIds = libraryFolderIds;
+ updatedType |= ItemUpdateType.MetadataImport;
}
+
+ return updatedType;
}
}
diff --git a/MediaBrowser.Providers/Channels/ChannelMetadataService.cs b/MediaBrowser.Providers/Channels/ChannelMetadataService.cs
index 0267fa13f..7e9b694b3 100644
--- a/MediaBrowser.Providers/Channels/ChannelMetadataService.cs
+++ b/MediaBrowser.Providers/Channels/ChannelMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Channels
+namespace MediaBrowser.Providers.Channels;
+
+/// <summary>
+/// Service to manage channel metadata.
+/// </summary>
+public class ChannelMetadataService : MetadataService<Channel, ItemLookupInfo>
{
- public class ChannelMetadataService : MetadataService<Channel, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ChannelMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public ChannelMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<ChannelMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public ChannelMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<ChannelMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs b/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs
index 0629824d3..9efef60a1 100644
--- a/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs
+++ b/MediaBrowser.Providers/Folders/CollectionFolderMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Folders
+namespace MediaBrowser.Providers.Folders;
+
+/// <summary>
+/// Service to manage collection folder metadata.
+/// </summary>
+public class CollectionFolderMetadataService : MetadataService<CollectionFolder, ItemLookupInfo>
{
- public class CollectionFolderMetadataService : MetadataService<CollectionFolder, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CollectionFolderMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public CollectionFolderMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<CollectionFolderMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public CollectionFolderMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<CollectionFolderMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/Folders/FolderMetadataService.cs b/MediaBrowser.Providers/Folders/FolderMetadataService.cs
index 79d52991a..272bb31e3 100644
--- a/MediaBrowser.Providers/Folders/FolderMetadataService.cs
+++ b/MediaBrowser.Providers/Folders/FolderMetadataService.cs
@@ -1,29 +1,43 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Folders
+namespace MediaBrowser.Providers.Folders;
+
+/// <summary>
+/// Service to manage folder metadata.
+/// </summary>
+public class FolderMetadataService : MetadataService<Folder, ItemLookupInfo>
{
- public class FolderMetadataService : MetadataService<Folder, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="FolderMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public FolderMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<FolderMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public FolderMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<FolderMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
-
- /// <inheritdoc />
- // Make sure the type-specific services get picked first
- public override int Order => 10;
}
+
+ /// <inheritdoc />
+ // Make sure the type-specific services get picked first
+ public override int Order => 10;
}
diff --git a/MediaBrowser.Providers/Folders/UserViewMetadataService.cs b/MediaBrowser.Providers/Folders/UserViewMetadataService.cs
index 79c5597e5..ab4bc917d 100644
--- a/MediaBrowser.Providers/Folders/UserViewMetadataService.cs
+++ b/MediaBrowser.Providers/Folders/UserViewMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Folders
+namespace MediaBrowser.Providers.Folders;
+
+/// <summary>
+/// Service to manage user view metadata.
+/// </summary>
+public class UserViewMetadataService : MetadataService<UserView, ItemLookupInfo>
{
- public class UserViewMetadataService : MetadataService<UserView, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="UserViewMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public UserViewMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<UserViewMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public UserViewMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<UserViewMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/Genres/GenreMetadataService.cs b/MediaBrowser.Providers/Genres/GenreMetadataService.cs
index 4d10d8987..0dd0384dc 100644
--- a/MediaBrowser.Providers/Genres/GenreMetadataService.cs
+++ b/MediaBrowser.Providers/Genres/GenreMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Genres
+namespace MediaBrowser.Providers.Genres;
+
+/// <summary>
+/// Service to manage genre metadata.
+/// </summary>
+public class GenreMetadataService : MetadataService<Genre, ItemLookupInfo>
{
- public class GenreMetadataService : MetadataService<Genre, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GenreMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public GenreMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<GenreMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public GenreMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<GenreMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs b/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs
index c94d36530..83f9984ea 100644
--- a/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs
+++ b/MediaBrowser.Providers/LiveTv/LiveTvMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.LiveTv
+namespace MediaBrowser.Providers.LiveTv;
+
+/// <summary>
+/// Service to manage live TV metadata.
+/// </summary>
+public class LiveTvMetadataService : MetadataService<LiveTvChannel, ItemLookupInfo>
{
- public class LiveTvMetadataService : MetadataService<LiveTvChannel, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LiveTvMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public LiveTvMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<LiveTvMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public LiveTvMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<LiveTvMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs
index 27d17b535..fa711eb28 100644
--- a/MediaBrowser.Providers/Lyric/LrcLyricParser.cs
+++ b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text;
using System.Text.RegularExpressions;
using Jellyfin.Extensions;
using LrcParser.Model;
@@ -66,47 +67,56 @@ public partial class LrcLyricParser : ILyricParser
}
List<LyricLine> lyricList = [];
- for (var l = 0; l < sortedLyricData.Count; l++)
+ for (var lineIndex = 0; lineIndex < sortedLyricData.Count; lineIndex++)
{
- var cues = new List<LyricLineCue>();
- var lyric = sortedLyricData[l];
+ var lyric = sortedLyricData[lineIndex];
- if (lyric.TimeTags.Count != 0)
+ // Extract cues from time tags
+ var cues = new List<LyricLineCue>();
+ if (lyric.TimeTags.Count > 0)
{
var keys = lyric.TimeTags.Keys.ToList();
- int current = 0, next = 1;
- while (next < keys.Count)
+ for (var tagIndex = 0; tagIndex < keys.Count - 1; tagIndex++)
{
- var currentKey = keys[current];
- var currentMs = lyric.TimeTags[currentKey] ?? 0;
- var nextMs = lyric.TimeTags[keys[next]] ?? 0;
-
- cues.Add(new LyricLineCue(
- position: Math.Max(currentKey.Index, 0),
- start: TimeSpan.FromMilliseconds(currentMs).Ticks,
- end: TimeSpan.FromMilliseconds(nextMs).Ticks));
+ var currentKey = keys[tagIndex];
+ var nextKey = keys[tagIndex + 1];
- current++;
- next++;
+ var currentPos = currentKey.State == IndexState.End ? currentKey.Index + 1 : currentKey.Index;
+ var nextPos = nextKey.State == IndexState.End ? nextKey.Index + 1 : nextKey.Index;
+ var currentMs = lyric.TimeTags[currentKey] ?? 0;
+ var nextMs = lyric.TimeTags[keys[tagIndex + 1]] ?? 0;
+ var currentSlice = lyric.Text[currentPos..nextPos];
+ var currentSliceTrimmed = currentSlice.Trim();
+ if (currentSliceTrimmed.Length > 0)
+ {
+ cues.Add(new LyricLineCue(
+ position: currentPos,
+ endPosition: nextPos,
+ start: TimeSpan.FromMilliseconds(currentMs).Ticks,
+ end: TimeSpan.FromMilliseconds(nextMs).Ticks));
+ }
}
- var lastKey = keys[current];
+ var lastKey = keys[^1];
+ var lastPos = lastKey.State == IndexState.End ? lastKey.Index + 1 : lastKey.Index;
var lastMs = lyric.TimeTags[lastKey] ?? 0;
+ var lastSlice = lyric.Text[lastPos..];
+ var lastSliceTrimmed = lastSlice.Trim();
- cues.Add(new LyricLineCue(
- position: Math.Max(lastKey.Index, 0),
- start: TimeSpan.FromMilliseconds(lastMs).Ticks,
- end: l + 1 < sortedLyricData.Count ? TimeSpan.FromMilliseconds(sortedLyricData[l + 1].StartTime).Ticks : null));
+ if (lastSliceTrimmed.Length > 0)
+ {
+ cues.Add(new LyricLineCue(
+ position: lastPos,
+ endPosition: lyric.Text.Length,
+ start: TimeSpan.FromMilliseconds(lastMs).Ticks,
+ end: lineIndex + 1 < sortedLyricData.Count ? TimeSpan.FromMilliseconds(sortedLyricData[lineIndex + 1].StartTime).Ticks : null));
+ }
}
long lyricStartTicks = TimeSpan.FromMilliseconds(lyric.StartTime).Ticks;
- lyricList.Add(new LyricLine(WhitespaceRegex().Replace(lyric.Text.Trim(), " "), lyricStartTicks, cues));
+ lyricList.Add(new LyricLine(lyric.Text, lyricStartTicks, cues));
}
return new LyricDto { Lyrics = lyricList };
}
-
- // Replacement is required until https://github.com/karaoke-dev/LrcParser/issues/83 is resolved.
- [GeneratedRegex(@"\s+")]
- private static partial Regex WhitespaceRegex();
}
diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs
index 8f6aa2db3..d9a8c044b 100644
--- a/MediaBrowser.Providers/Manager/ImageSaver.cs
+++ b/MediaBrowser.Providers/Manager/ImageSaver.cs
@@ -483,6 +483,22 @@ namespace MediaBrowser.Providers.Manager
}
}
+ if (type == ImageType.Logo && saveLocally)
+ {
+ if (season is not null && season.IndexNumber.HasValue)
+ {
+ var seriesFolder = season.SeriesPath;
+
+ var seasonMarker = season.IndexNumber.Value == 0
+ ? "-specials"
+ : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
+
+ var imageFilename = "season" + seasonMarker + "-logo" + extension;
+
+ return Path.Combine(seriesFolder, imageFilename);
+ }
+ }
+
string filename;
var folderName = item is MusicAlbum ||
item is MusicArtist ||
diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
index ee22b4bc6..75882a088 100644
--- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs
+++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
@@ -377,6 +377,10 @@ namespace MediaBrowser.Providers.Manager
{
// Nothing to do, already gone
}
+ catch (DirectoryNotFoundException)
+ {
+ // Nothing to do, already gone
+ }
catch (UnauthorizedAccessException ex)
{
_logger.LogWarning(ex, "Unable to delete {Image}", image.Path);
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index 45f66f85f..f220ec4a1 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -12,7 +12,9 @@ using Jellyfin.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@@ -26,13 +28,22 @@ namespace MediaBrowser.Providers.Manager
where TItemType : BaseItem, IHasLookupInfo<TIdType>, new()
where TIdType : ItemLookupInfo, new()
{
- protected MetadataService(IServerConfigurationManager serverConfigurationManager, ILogger<MetadataService<TItemType, TIdType>> logger, IProviderManager providerManager, IFileSystem fileSystem, ILibraryManager libraryManager)
+ protected MetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<MetadataService<TItemType, TIdType>> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
{
ServerConfigurationManager = serverConfigurationManager;
Logger = logger;
ProviderManager = providerManager;
FileSystem = fileSystem;
LibraryManager = libraryManager;
+ ExternalDataManager = externalDataManager;
+ ItemRepository = itemRepository;
ImageProvider = new ItemImageProvider(Logger, ProviderManager, FileSystem);
}
@@ -48,6 +59,10 @@ namespace MediaBrowser.Providers.Manager
protected ILibraryManager LibraryManager { get; }
+ protected IExternalDataManager ExternalDataManager { get; }
+
+ protected IItemRepository ItemRepository { get; }
+
protected virtual bool EnableUpdatingPremiereDateFromChildren => false;
protected virtual bool EnableUpdatingGenresFromChildren => false;
@@ -58,11 +73,11 @@ namespace MediaBrowser.Providers.Manager
public virtual int Order => 0;
- private FileSystemMetadata TryGetFile(string path, IDirectoryService directoryService)
+ private FileSystemMetadata TryGetFileSystemMetadata(string path, IDirectoryService directoryService)
{
try
{
- return directoryService.GetFile(path);
+ return directoryService.GetFileSystemEntry(path);
}
catch (Exception ex)
{
@@ -75,8 +90,9 @@ namespace MediaBrowser.Providers.Manager
{
var itemOfType = (TItemType)item;
var updateType = ItemUpdateType.None;
+
var libraryOptions = LibraryManager.GetLibraryOptions(item);
- var isFirstRefresh = item.DateLastRefreshed == default;
+ var isFirstRefresh = item.DateLastRefreshed == DateTime.MinValue;
var hasRefreshedMetadata = true;
var hasRefreshedImages = true;
@@ -128,14 +144,17 @@ namespace MediaBrowser.Providers.Manager
var metadataResult = new MetadataResult<TItemType>
{
- Item = itemOfType,
- People = LibraryManager.GetPeople(item)
+ Item = itemOfType
};
- var beforeSaveResult = BeforeSave(itemOfType, isFirstRefresh || refreshOptions.ReplaceAllMetadata || refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || requiresRefresh || refreshOptions.ForceSave, updateType);
+ var beforeSaveResult = await BeforeSave(itemOfType, isFirstRefresh || refreshOptions.ReplaceAllMetadata || refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || requiresRefresh || refreshOptions.ForceSave, updateType)
+ .ConfigureAwait(false);
updateType |= beforeSaveResult;
- updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false);
+ if (!isFirstRefresh)
+ {
+ updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false);
+ }
// Next run metadata providers
if (refreshOptions.MetadataRefreshMode != MetadataRefreshMode.None)
@@ -209,10 +228,15 @@ namespace MediaBrowser.Providers.Manager
{
if (item.IsFileProtocol)
{
- var file = TryGetFile(item.Path, refreshOptions.DirectoryService);
+ var file = TryGetFileSystemMetadata(item.Path, refreshOptions.DirectoryService);
if (file is not null)
{
item.DateModified = file.LastWriteTimeUtc;
+
+ if (!file.IsDirectory)
+ {
+ item.Size = file.Length;
+ }
}
}
@@ -253,14 +277,13 @@ namespace MediaBrowser.Providers.Manager
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken)
{
- if (result.Item.SupportsPeople)
+ await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
+ if (result.Item.SupportsPeople && result.People is not null)
{
var baseItem = result.Item;
await LibraryManager.UpdatePeopleAsync(baseItem, result.People, cancellationToken).ConfigureAwait(false);
}
-
- await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
}
protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
@@ -276,12 +299,20 @@ namespace MediaBrowser.Providers.Manager
/// <param name="isFullRefresh">if set to <c>true</c> [is full refresh].</param>
/// <param name="currentUpdateType">Type of the current update.</param>
/// <returns>ItemUpdateType.</returns>
- private ItemUpdateType BeforeSave(TItemType item, bool isFullRefresh, ItemUpdateType currentUpdateType)
+ private async Task<ItemUpdateType> BeforeSave(TItemType item, bool isFullRefresh, ItemUpdateType currentUpdateType)
{
var updateType = BeforeSaveInternal(item, isFullRefresh, currentUpdateType);
updateType |= item.OnMetadataChanged();
+ if (updateType == ItemUpdateType.None)
+ {
+ if (!await ItemRepository.ItemExistsAsync(item.Id).ConfigureAwait(false))
+ {
+ return ItemUpdateType.MetadataImport;
+ }
+ }
+
return updateType;
}
@@ -289,12 +320,8 @@ namespace MediaBrowser.Providers.Manager
{
if (EnableUpdateMetadataFromChildren(item, isFullRefresh, updateType))
{
- if (isFullRefresh || updateType > ItemUpdateType.None)
- {
- var children = GetChildrenForMetadataUpdates(item);
-
- updateType = UpdateMetadataFromChildren(item, children, isFullRefresh, updateType);
- }
+ var children = GetChildrenForMetadataUpdates(item);
+ updateType = UpdateMetadataFromChildren(item, children, isFullRefresh, updateType);
}
var presentationUniqueKey = item.CreatePresentationUniqueKey();
@@ -304,21 +331,57 @@ namespace MediaBrowser.Providers.Manager
updateType |= ItemUpdateType.MetadataImport;
}
+ // Cleanup extracted files if source file was modified
+ var itemPath = item.Path;
+ if (!string.IsNullOrEmpty(itemPath))
+ {
+ var info = FileSystem.GetFileSystemInfo(itemPath);
+ if (info.Exists && item.HasChanged(info.LastWriteTimeUtc))
+ {
+ Logger.LogDebug("File modification time changed from {Then} to {Now}: {Path}", item.DateModified, info.LastWriteTimeUtc, itemPath);
+
+ item.DateModified = info.LastWriteTimeUtc;
+ if (ServerConfigurationManager.GetMetadataConfiguration().UseFileCreationTimeForDateAdded)
+ {
+ if (info.CreationTimeUtc > DateTime.MinValue)
+ {
+ item.DateCreated = info.CreationTimeUtc;
+ }
+ }
+
+ if (item is Video video)
+ {
+ Logger.LogInformation("File changed, pruning extracted data: {Path}", item.Path);
+ ExternalDataManager.DeleteExternalItemDataAsync(video, CancellationToken.None).GetAwaiter().GetResult();
+ }
+
+ updateType |= ItemUpdateType.MetadataImport;
+ }
+ }
+
return updateType;
}
protected virtual bool EnableUpdateMetadataFromChildren(TItemType item, bool isFullRefresh, ItemUpdateType currentUpdateType)
{
- if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
+ if (item is Folder folder)
{
- if (EnableUpdatingPremiereDateFromChildren || EnableUpdatingGenresFromChildren || EnableUpdatingStudiosFromChildren || EnableUpdatingOfficialRatingFromChildren)
+ if (!isFullRefresh && currentUpdateType == ItemUpdateType.None)
{
- return true;
+ return folder.SupportsDateLastMediaAdded;
}
- if (item is Folder folder)
+ if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
{
- return folder.SupportsDateLastMediaAdded || folder.SupportsCumulativeRunTimeTicks;
+ if (EnableUpdatingPremiereDateFromChildren || EnableUpdatingGenresFromChildren || EnableUpdatingStudiosFromChildren || EnableUpdatingOfficialRatingFromChildren)
+ {
+ return true;
+ }
+
+ if (folder.SupportsDateLastMediaAdded || folder.SupportsCumulativeRunTimeTicks)
+ {
+ return true;
+ }
}
}
@@ -339,36 +402,42 @@ namespace MediaBrowser.Providers.Manager
{
var updateType = ItemUpdateType.None;
- if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
+ if (item is Folder folder)
{
- updateType |= UpdateCumulativeRunTimeTicks(item, children);
- updateType |= UpdateDateLastMediaAdded(item, children);
-
- // don't update user-changeable metadata for locked items
- if (item.IsLocked)
+ if (folder.SupportsDateLastMediaAdded)
{
- return updateType;
+ updateType |= UpdateDateLastMediaAdded(item, children);
}
- if (EnableUpdatingPremiereDateFromChildren)
+ if ((isFullRefresh || currentUpdateType > ItemUpdateType.None) && folder.SupportsCumulativeRunTimeTicks)
{
- updateType |= UpdatePremiereDate(item, children);
+ updateType |= UpdateCumulativeRunTimeTicks(item, children);
}
+ }
- if (EnableUpdatingGenresFromChildren)
- {
- updateType |= UpdateGenres(item, children);
- }
+ if (!(isFullRefresh || currentUpdateType > ItemUpdateType.None) || item.IsLocked)
+ {
+ return updateType;
+ }
- if (EnableUpdatingStudiosFromChildren)
- {
- updateType |= UpdateStudios(item, children);
- }
+ if (EnableUpdatingPremiereDateFromChildren)
+ {
+ updateType |= UpdatePremiereDate(item, children);
+ }
- if (EnableUpdatingOfficialRatingFromChildren)
- {
- updateType |= UpdateOfficialRating(item, children);
- }
+ if (EnableUpdatingGenresFromChildren)
+ {
+ updateType |= UpdateGenres(item, children);
+ }
+
+ if (EnableUpdatingStudiosFromChildren)
+ {
+ updateType |= UpdateStudios(item, children);
+ }
+
+ if (EnableUpdatingOfficialRatingFromChildren)
+ {
+ updateType |= UpdateOfficialRating(item, children);
}
return updateType;
@@ -614,7 +683,7 @@ namespace MediaBrowser.Providers.Manager
var dateLastImageRefresh = item.DateLastRefreshed;
// Run all if either of these flags are true
- var runAllProviders = options.ImageRefreshMode == MetadataRefreshMode.FullRefresh || dateLastImageRefresh == default(DateTime);
+ var runAllProviders = options.ImageRefreshMode == MetadataRefreshMode.FullRefresh || dateLastImageRefresh.Date == DateTime.MinValue.Date;
if (!runAllProviders)
{
@@ -781,7 +850,9 @@ namespace MediaBrowser.Providers.Manager
}
else
{
- var shouldReplace = options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly || options.ReplaceAllMetadata;
+ var shouldReplace = (options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly && options.ReplaceAllMetadata)
+ // Case for Scan for new and updated files
+ || (options.MetadataRefreshMode == MetadataRefreshMode.Default && !options.ReplaceAllMetadata);
MergeData(temp, metadata, item.LockedFields, shouldReplace, true);
}
}
@@ -1042,7 +1113,7 @@ namespace MediaBrowser.Providers.Manager
}
else
{
- target.Studios = target.Studios.Concat(source.Studios).Distinct().ToArray();
+ target.Studios = target.Studios.Concat(source.Studios).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}
@@ -1054,7 +1125,7 @@ namespace MediaBrowser.Providers.Manager
}
else
{
- target.Tags = target.Tags.Concat(source.Tags).Distinct().ToArray();
+ target.Tags = target.Tags.Concat(source.Tags).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}
@@ -1066,7 +1137,7 @@ namespace MediaBrowser.Providers.Manager
}
else
{
- target.ProductionLocations = target.ProductionLocations.Concat(source.ProductionLocations).Distinct().ToArray();
+ target.ProductionLocations = target.ProductionLocations.Concat(source.ProductionLocations).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}
@@ -1128,11 +1199,16 @@ namespace MediaBrowser.Providers.Manager
target.LockedFields = target.LockedFields.Concat(source.LockedFields).Distinct().ToArray();
}
- if (source.DateCreated != default)
+ if (source.DateCreated != DateTime.MinValue)
{
target.DateCreated = source.DateCreated;
}
+ if (replaceData || source.DateModified != DateTime.MinValue)
+ {
+ target.DateModified = source.DateModified;
+ }
+
if (replaceData || string.IsNullOrEmpty(target.PreferredMetadataCountryCode))
{
target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode;
@@ -1215,7 +1291,7 @@ namespace MediaBrowser.Providers.Manager
}
else if (sourceHasAlbumArtist.AlbumArtists.Count > 0)
{
- targetHasAlbumArtist.AlbumArtists = targetHasAlbumArtist.AlbumArtists.Concat(sourceHasAlbumArtist.AlbumArtists).Distinct().ToArray();
+ targetHasAlbumArtist.AlbumArtists = targetHasAlbumArtist.AlbumArtists.Concat(sourceHasAlbumArtist.AlbumArtists).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}
}
@@ -1224,7 +1300,7 @@ namespace MediaBrowser.Providers.Manager
{
if (source is Video sourceCast && target is Video targetCast)
{
- if (replaceData || !targetCast.Video3DFormat.HasValue)
+ if (sourceCast.Video3DFormat.HasValue && (replaceData || !targetCast.Video3DFormat.HasValue))
{
targetCast.Video3DFormat = sourceCast.Video3DFormat;
}
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index 856f33b49..43f0746ba 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -1,13 +1,11 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Mime;
-using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
@@ -24,6 +22,7 @@ using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Configuration;
@@ -670,8 +669,13 @@ namespace MediaBrowser.Providers.Manager
private async Task SaveMetadataAsync(BaseItem item, ItemUpdateType updateType, IEnumerable<IMetadataSaver> savers)
{
var libraryOptions = _libraryManager.GetLibraryOptions(item);
+ var applicableSavers = savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, updateType, false)).ToList();
+ if (applicableSavers.Count == 0)
+ {
+ return;
+ }
- foreach (var saver in savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, updateType, false)))
+ foreach (var saver in applicableSavers)
{
_logger.LogDebug("Saving {Item} to {Saver}", item.Path ?? item.Name, saver.Name);
@@ -693,6 +697,7 @@ namespace MediaBrowser.Providers.Manager
{
_libraryMonitor.ReportFileSystemChangeBeginning(path);
await saver.SaveAsync(item, CancellationToken.None).ConfigureAwait(false);
+ item.DateLastSaved = DateTime.UtcNow;
}
catch (Exception ex)
{
@@ -708,6 +713,7 @@ namespace MediaBrowser.Providers.Manager
try
{
await saver.SaveAsync(item, CancellationToken.None).ConfigureAwait(false);
+ item.DateLastSaved = DateTime.UtcNow;
}
catch (Exception ex)
{
@@ -715,6 +721,8 @@ namespace MediaBrowser.Providers.Manager
}
}
}
+
+ _libraryManager.CreateItem(item, null);
}
/// <summary>
diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
index 0bb21b287..869e3f292 100644
--- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
+++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
@@ -32,7 +32,6 @@ namespace MediaBrowser.Providers.MediaInfo
private const char InternalValueSeparator = '\u001F';
private readonly IMediaEncoder _mediaEncoder;
- private readonly IItemRepository _itemRepo;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<AudioFileProber> _logger;
private readonly IMediaSourceManager _mediaSourceManager;
@@ -46,7 +45,6 @@ namespace MediaBrowser.Providers.MediaInfo
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
- /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param>
/// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
@@ -55,14 +53,12 @@ namespace MediaBrowser.Providers.MediaInfo
ILogger<AudioFileProber> logger,
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
- IItemRepository itemRepo,
ILibraryManager libraryManager,
LyricResolver lyricResolver,
ILyricManager lyricManager,
IMediaStreamRepository mediaStreamRepository)
{
_mediaEncoder = mediaEncoder;
- _itemRepo = itemRepo;
_libraryManager = libraryManager;
_logger = logger;
_mediaSourceManager = mediaSourceManager;
@@ -137,7 +133,6 @@ namespace MediaBrowser.Providers.MediaInfo
audio.TotalBitrate = mediaInfo.Bitrate;
audio.RunTimeTicks = mediaInfo.RunTimeTicks;
- audio.Size = mediaInfo.Size;
// Add external lyrics first to prevent the lrc file get overwritten on first scan
var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
@@ -186,10 +181,31 @@ namespace MediaBrowser.Providers.MediaInfo
var trackTrackNumber = track.TrackNumber is null or 0 ? mediaInfo.IndexNumber : track.TrackNumber;
var trackDiscNumber = track.DiscNumber is null or 0 ? mediaInfo.ParentIndexNumber : track.DiscNumber;
+ // Some users may use a misbehaved tag editor that writes a null character in the tag when not allowed by the standard.
+ trackTitle = GetSanitizedStringTag(trackTitle, audio.Path);
+ trackAlbum = GetSanitizedStringTag(trackAlbum, audio.Path);
+ var trackAlbumArtist = GetSanitizedStringTag(track.AlbumArtist, audio.Path);
+ var trackArist = GetSanitizedStringTag(track.Artist, audio.Path);
+ var trackComposer = GetSanitizedStringTag(track.Composer, audio.Path);
+ var trackGenre = GetSanitizedStringTag(track.Genre, audio.Path);
+
if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
{
var people = new List<PersonInfo>();
- var albumArtists = string.IsNullOrEmpty(track.AlbumArtist) ? [] : track.AlbumArtist.Split(InternalValueSeparator);
+ string[]? albumArtists = null;
+ if (libraryOptions.PreferNonstandardArtistsTag)
+ {
+ TryGetSanitizedAdditionalFields(track, "ALBUMARTISTS", out var albumArtistsTagString);
+ if (albumArtistsTagString is not null)
+ {
+ albumArtists = albumArtistsTagString.Split(InternalValueSeparator);
+ }
+ }
+
+ if (albumArtists is null || albumArtists.Length == 0)
+ {
+ albumArtists = string.IsNullOrEmpty(trackAlbumArtist) ? [] : trackAlbumArtist.Split(InternalValueSeparator);
+ }
if (libraryOptions.UseCustomTagDelimiters)
{
@@ -202,7 +218,7 @@ namespace MediaBrowser.Providers.MediaInfo
{
PeopleHelper.AddPerson(people, new PersonInfo
{
- Name = albumArtist.Trim(),
+ Name = albumArtist,
Type = PersonKind.AlbumArtist
});
}
@@ -211,7 +227,7 @@ namespace MediaBrowser.Providers.MediaInfo
string[]? performers = null;
if (libraryOptions.PreferNonstandardArtistsTag)
{
- track.AdditionalFields.TryGetValue("ARTISTS", out var artistsTagString);
+ TryGetSanitizedAdditionalFields(track, "ARTISTS", out var artistsTagString);
if (artistsTagString is not null)
{
performers = artistsTagString.Split(InternalValueSeparator);
@@ -220,7 +236,7 @@ namespace MediaBrowser.Providers.MediaInfo
if (performers is null || performers.Length == 0)
{
- performers = string.IsNullOrEmpty(track.Artist) ? [] : track.Artist.Split(InternalValueSeparator);
+ performers = string.IsNullOrEmpty(trackArist) ? [] : trackArist.Split(InternalValueSeparator);
}
if (libraryOptions.UseCustomTagDelimiters)
@@ -234,21 +250,24 @@ namespace MediaBrowser.Providers.MediaInfo
{
PeopleHelper.AddPerson(people, new PersonInfo
{
- Name = performer.Trim(),
+ Name = performer,
Type = PersonKind.Artist
});
}
}
- foreach (var composer in track.Composer.Split(InternalValueSeparator))
+ if (!string.IsNullOrWhiteSpace(trackComposer))
{
- if (!string.IsNullOrWhiteSpace(composer))
+ foreach (var composer in trackComposer.Split(InternalValueSeparator))
{
- PeopleHelper.AddPerson(people, new PersonInfo
+ if (!string.IsNullOrWhiteSpace(composer))
{
- Name = composer.Trim(),
- Type = PersonKind.Composer
- });
+ PeopleHelper.AddPerson(people, new PersonInfo
+ {
+ Name = composer,
+ Type = PersonKind.Composer
+ });
+ }
}
}
@@ -325,7 +344,7 @@ namespace MediaBrowser.Providers.MediaInfo
if (!audio.LockedFields.Contains(MetadataField.Genres))
{
- var genres = string.IsNullOrEmpty(track.Genre) ? [] : track.Genre.Split(InternalValueSeparator).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ var genres = string.IsNullOrEmpty(trackGenre) ? [] : trackGenre.Split(InternalValueSeparator).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (libraryOptions.UseCustomTagDelimiters)
{
@@ -334,12 +353,13 @@ namespace MediaBrowser.Providers.MediaInfo
genres = genres.Trimmed().Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
- audio.Genres = options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0
- ? genres
- : audio.Genres;
+ if (options.ReplaceAllMetadata || audio.Genres is null || audio.Genres.Length == 0 || audio.Genres.All(string.IsNullOrWhiteSpace))
+ {
+ audio.Genres = genres;
+ }
}
- track.AdditionalFields.TryGetValue("REPLAYGAIN_TRACK_GAIN", out var trackGainTag);
+ TryGetSanitizedAdditionalFields(track, "REPLAYGAIN_TRACK_GAIN", out var trackGainTag);
if (trackGainTag is not null)
{
@@ -348,7 +368,7 @@ namespace MediaBrowser.Providers.MediaInfo
trackGainTag = trackGainTag[..^2].Trim();
}
- if (float.TryParse(trackGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var value))
+ if (float.TryParse(trackGainTag, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) && float.IsFinite(value))
{
audio.NormalizationGain = value;
}
@@ -356,8 +376,8 @@ namespace MediaBrowser.Providers.MediaInfo
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
{
- if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag)
- || track.AdditionalFields.TryGetValue("MusicBrainz Artist Id", out musicBrainzArtistTag))
+ if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ARTISTID", out var musicBrainzArtistTag)
+ || TryGetSanitizedAdditionalFields(track, "MusicBrainz Artist Id", out musicBrainzArtistTag))
&& !string.IsNullOrEmpty(musicBrainzArtistTag))
{
var id = GetFirstMusicBrainzId(musicBrainzArtistTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
@@ -367,8 +387,8 @@ namespace MediaBrowser.Providers.MediaInfo
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
{
- if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ALBUMARTISTID", out var musicBrainzReleaseArtistIdTag)
- || track.AdditionalFields.TryGetValue("MusicBrainz Album Artist Id", out musicBrainzReleaseArtistIdTag))
+ if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ALBUMARTISTID", out var musicBrainzReleaseArtistIdTag)
+ || TryGetSanitizedAdditionalFields(track, "MusicBrainz Album Artist Id", out musicBrainzReleaseArtistIdTag))
&& !string.IsNullOrEmpty(musicBrainzReleaseArtistIdTag))
{
var id = GetFirstMusicBrainzId(musicBrainzReleaseArtistIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
@@ -378,8 +398,8 @@ namespace MediaBrowser.Providers.MediaInfo
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
{
- if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_ALBUMID", out var musicBrainzReleaseIdTag)
- || track.AdditionalFields.TryGetValue("MusicBrainz Album Id", out musicBrainzReleaseIdTag))
+ if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_ALBUMID", out var musicBrainzReleaseIdTag)
+ || TryGetSanitizedAdditionalFields(track, "MusicBrainz Album Id", out musicBrainzReleaseIdTag))
&& !string.IsNullOrEmpty(musicBrainzReleaseIdTag))
{
var id = GetFirstMusicBrainzId(musicBrainzReleaseIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
@@ -389,8 +409,8 @@ namespace MediaBrowser.Providers.MediaInfo
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
{
- if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_RELEASEGROUPID", out var musicBrainzReleaseGroupIdTag)
- || track.AdditionalFields.TryGetValue("MusicBrainz Release Group Id", out musicBrainzReleaseGroupIdTag))
+ if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_RELEASEGROUPID", out var musicBrainzReleaseGroupIdTag)
+ || TryGetSanitizedAdditionalFields(track, "MusicBrainz Release Group Id", out musicBrainzReleaseGroupIdTag))
&& !string.IsNullOrEmpty(musicBrainzReleaseGroupIdTag))
{
var id = GetFirstMusicBrainzId(musicBrainzReleaseGroupIdTag, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
@@ -400,8 +420,8 @@ namespace MediaBrowser.Providers.MediaInfo
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
{
- if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_RELEASETRACKID", out var trackMbId)
- || track.AdditionalFields.TryGetValue("MusicBrainz Release Track Id", out trackMbId))
+ if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_RELEASETRACKID", out var trackMbId)
+ || TryGetSanitizedAdditionalFields(track, "MusicBrainz Release Track Id", out trackMbId))
&& !string.IsNullOrEmpty(trackMbId))
{
var id = GetFirstMusicBrainzId(trackMbId, libraryOptions.UseCustomTagDelimiters, libraryOptions.GetCustomTagDelimiters(), libraryOptions.DelimiterWhitelist);
@@ -411,25 +431,29 @@ namespace MediaBrowser.Providers.MediaInfo
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzRecording, out _))
{
- if ((track.AdditionalFields.TryGetValue("MUSICBRAINZ_TRACKID", out var recordingMbId)
- || track.AdditionalFields.TryGetValue("MusicBrainz Track Id", out recordingMbId))
+ if ((TryGetSanitizedAdditionalFields(track, "MUSICBRAINZ_TRACKID", out var recordingMbId)
+ || TryGetSanitizedAdditionalFields(track, "MusicBrainz Track Id", out recordingMbId))
&& !string.IsNullOrEmpty(recordingMbId))
{
audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, recordingMbId);
}
- else if (track.AdditionalFields.TryGetValue("UFID", out var ufIdValue) && !string.IsNullOrEmpty(ufIdValue))
+ else if (TryGetSanitizedUFIDFields(track, out var owner, out var identifier) && !string.IsNullOrEmpty(owner) && !string.IsNullOrEmpty(identifier))
{
// If tagged with MB Picard, the format is 'http://musicbrainz.org\0<recording MBID>'
- if (ufIdValue.Contains("musicbrainz.org", StringComparison.OrdinalIgnoreCase))
+ if (owner.Contains("musicbrainz.org", StringComparison.OrdinalIgnoreCase))
{
- audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, ufIdValue.AsSpan().RightPart('\0').ToString());
+ audio.TrySetProviderId(MetadataProvider.MusicBrainzRecording, identifier);
}
}
}
// Save extracted lyrics if they exist,
// and if the audio doesn't yet have lyrics.
- var lyrics = track.Lyrics.SynchronizedLyrics.Count > 0 ? track.Lyrics.FormatSynchToLRC() : track.Lyrics.UnsynchronizedLyrics;
+ // ATL supports both SRT and LRC formats as synchronized lyrics, but we only want to save LRC format.
+ var supportedLyrics = track.Lyrics.Where(l => l.Format != LyricsInfo.LyricsFormat.SRT).ToList();
+ var candidateSynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is not LyricsInfo.LyricsFormat.UNSYNCHRONIZED and not LyricsInfo.LyricsFormat.OTHER && l.SynchronizedLyrics is not null);
+ var candidateUnsynchronizedLyric = supportedLyrics.FirstOrDefault(l => l.Format is LyricsInfo.LyricsFormat.UNSYNCHRONIZED or LyricsInfo.LyricsFormat.OTHER && l.UnsynchronizedLyrics is not null);
+ var lyrics = candidateSynchronizedLyric is not null ? candidateSynchronizedLyric.FormatSynch() : candidateUnsynchronizedLyric?.UnsynchronizedLyrics;
if (!string.IsNullOrWhiteSpace(lyrics)
&& tryExtractEmbeddedLyrics)
{
@@ -490,5 +514,79 @@ namespace MediaBrowser.Providers.MediaInfo
return val;
}
+
+ private string? GetSanitizedStringTag(string? tag, string filePath)
+ {
+ if (string.IsNullOrEmpty(tag))
+ {
+ return null;
+ }
+
+ var result = tag.TruncateAtNull();
+ if (result.Length != tag.Length)
+ {
+ _logger.LogWarning("Audio file {File} contains a null character in its tag, but this is not allowed by its tagging standard. All characters after the null char will be discarded. Please fix your file", filePath);
+ }
+
+ return result;
+ }
+
+ private bool TryGetSanitizedAdditionalFields(Track track, string field, out string? value)
+ {
+ var hasField = TryGetAdditionalFieldWithFallback(track, field, out value);
+ value = GetSanitizedStringTag(value, track.Path);
+ return hasField;
+ }
+
+ private bool TryGetSanitizedUFIDFields(Track track, out string? owner, out string? identifier)
+ {
+ var hasField = TryGetAdditionalFieldWithFallback(track, "UFID", out string? value);
+ if (hasField && !string.IsNullOrEmpty(value))
+ {
+ string[] parts = value.Split('\0');
+ if (parts.Length == 2)
+ {
+ owner = GetSanitizedStringTag(parts[0], track.Path);
+ identifier = GetSanitizedStringTag(parts[1], track.Path);
+ return true;
+ }
+ }
+
+ owner = null;
+ identifier = null;
+ return false;
+ }
+
+ // Build the explicit mka-style fallback key (e.g., ARTISTS -> track.artists, "MusicBrainz Artist Id" -> track.musicbrainz_artist_id)
+ private static string GetMkaFallbackKey(string key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ return key;
+ }
+
+ var normalized = key.Trim().Replace(' ', '_').ToLowerInvariant();
+ return "track." + normalized;
+ }
+
+ // First try the normal key exactly; if missing, try the mka-style fallback key.
+ private bool TryGetAdditionalFieldWithFallback(Track track, string key, out string? value)
+ {
+ // Prefer the normal key (as-is, case-sensitive)
+ if (track.AdditionalFields.TryGetValue(key, out value))
+ {
+ return true;
+ }
+
+ // Fallback to mka-style: "track." + lower-case(original key)
+ var fallbackKey = GetMkaFallbackKey(key);
+ if (track.AdditionalFields.TryGetValue(fallbackKey, out value))
+ {
+ return true;
+ }
+
+ value = null;
+ return false;
+ }
}
}
diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
index 266e1861f..bde23e842 100644
--- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
+++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
@@ -6,6 +6,7 @@ using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Chapters;
@@ -34,13 +35,11 @@ namespace MediaBrowser.Providers.MediaInfo
private readonly ILogger<FFProbeVideoInfo> _logger;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IMediaEncoder _mediaEncoder;
- private readonly IItemRepository _itemRepo;
private readonly IBlurayExaminer _blurayExaminer;
private readonly ILocalizationManager _localization;
- private readonly IEncodingManager _encodingManager;
+ private readonly IChapterManager _chapterManager;
private readonly IServerConfigurationManager _config;
private readonly ISubtitleManager _subtitleManager;
- private readonly IChapterRepository _chapterManager;
private readonly ILibraryManager _libraryManager;
private readonly AudioResolver _audioResolver;
private readonly SubtitleResolver _subtitleResolver;
@@ -51,13 +50,11 @@ namespace MediaBrowser.Providers.MediaInfo
ILogger<FFProbeVideoInfo> logger,
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
- IItemRepository itemRepo,
IBlurayExaminer blurayExaminer,
ILocalizationManager localization,
- IEncodingManager encodingManager,
+ IChapterManager chapterManager,
IServerConfigurationManager config,
ISubtitleManager subtitleManager,
- IChapterRepository chapterManager,
ILibraryManager libraryManager,
AudioResolver audioResolver,
SubtitleResolver subtitleResolver,
@@ -67,13 +64,11 @@ namespace MediaBrowser.Providers.MediaInfo
_logger = logger;
_mediaSourceManager = mediaSourceManager;
_mediaEncoder = mediaEncoder;
- _itemRepo = itemRepo;
_blurayExaminer = blurayExaminer;
_localization = localization;
- _encodingManager = encodingManager;
+ _chapterManager = chapterManager;
_config = config;
_subtitleManager = subtitleManager;
- _chapterManager = chapterManager;
_libraryManager = libraryManager;
_audioResolver = audioResolver;
_subtitleResolver = subtitleResolver;
@@ -220,10 +215,14 @@ namespace MediaBrowser.Providers.MediaInfo
mediaAttachments = mediaInfo.MediaAttachments;
video.TotalBitrate = mediaInfo.Bitrate;
video.RunTimeTicks = mediaInfo.RunTimeTicks;
- video.Size = mediaInfo.Size;
video.Container = mediaInfo.Container;
+ var videoType = video.VideoType;
+ if (videoType == VideoType.BluRay || videoType == VideoType.Dvd)
+ {
+ video.Size = mediaInfo.Size;
+ }
- chapters = mediaInfo.Chapters ?? Array.Empty<ChapterInfo>();
+ chapters = mediaInfo.Chapters ?? [];
if (blurayInfo is not null)
{
FetchBdInfo(video, ref chapters, mediaStreams, blurayInfo);
@@ -240,8 +239,8 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
- mediaAttachments = Array.Empty<MediaAttachment>();
- chapters = Array.Empty<ChapterInfo>();
+ mediaAttachments = [];
+ chapters = [];
}
var libraryOptions = _libraryManager.GetLibraryOptions(video);
@@ -277,10 +276,7 @@ namespace MediaBrowser.Providers.MediaInfo
_mediaStreamRepository.SaveMediaStreams(video.Id, mediaStreams, cancellationToken);
- if (mediaAttachments.Any())
- {
- _mediaAttachmentRepository.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken);
- }
+ _mediaAttachmentRepository.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken);
if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh
|| options.MetadataRefreshMode == MetadataRefreshMode.Default)
@@ -298,9 +294,9 @@ namespace MediaBrowser.Providers.MediaInfo
extractDuringScan = libraryOptions.ExtractChapterImagesDuringLibraryScan;
}
- await _encodingManager.RefreshChapterImages(video, options.DirectoryService, chapters, extractDuringScan, false, cancellationToken).ConfigureAwait(false);
+ await _chapterManager.RefreshChapterImages(video, options.DirectoryService, chapters, extractDuringScan, false, cancellationToken).ConfigureAwait(false);
- _chapterManager.SaveChapters(video.Id, chapters);
+ _chapterManager.SaveChapters(video, chapters);
}
}
@@ -324,16 +320,19 @@ namespace MediaBrowser.Providers.MediaInfo
private void FetchBdInfo(Video video, ref ChapterInfo[] chapters, List<MediaStream> mediaStreams, BlurayDiscInfo blurayInfo)
{
- if (blurayInfo.Files.Length <= 1)
- {
- return;
- }
-
var ffmpegVideoStream = mediaStreams.FirstOrDefault(s => s.Type == MediaStreamType.Video);
+ var externalStreams = mediaStreams.Where(s => s.IsExternal).ToList();
// Fill video properties from the BDInfo result
mediaStreams.Clear();
- mediaStreams.AddRange(blurayInfo.MediaStreams);
+
+ // Rebuild the list with external streams first
+ int index = 0;
+ foreach (var stream in externalStreams.Concat(blurayInfo.MediaStreams))
+ {
+ stream.Index = index++;
+ mediaStreams.Add(stream);
+ }
if (blurayInfo.RunTimeTicks.HasValue && blurayInfo.RunTimeTicks.Value > 0)
{
@@ -406,7 +405,7 @@ namespace MediaBrowser.Providers.MediaInfo
{
if (video.Genres.Length == 0 || replaceData)
{
- video.Genres = Array.Empty<string>();
+ video.Genres = [];
foreach (var genre in data.Genres.Trimmed())
{
@@ -515,12 +514,15 @@ namespace MediaBrowser.Providers.MediaInfo
foreach (var person in data.People)
{
- PeopleHelper.AddPerson(people, new PersonInfo
+ if (!string.IsNullOrWhiteSpace(person.Name))
{
- Name = person.Name.Trim(),
- Type = person.Type,
- Role = person.Role.Trim()
- });
+ PeopleHelper.AddPerson(people, new PersonInfo
+ {
+ Name = person.Name,
+ Type = person.Type,
+ Role = person.Role?.Trim()
+ });
+ }
}
_libraryManager.UpdatePeople(video, people);
@@ -649,7 +651,7 @@ namespace MediaBrowser.Providers.MediaInfo
long dummyChapterDuration = TimeSpan.FromSeconds(_config.Configuration.DummyChapterDuration).Ticks;
if (runtime <= dummyChapterDuration)
{
- return Array.Empty<ChapterInfo>();
+ return [];
}
int chapterCount = (int)(runtime / dummyChapterDuration);
diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
index 1c2f8b913..9f5463b82 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -55,13 +55,11 @@ namespace MediaBrowser.Providers.MediaInfo
/// </summary>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
- /// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
/// <param name="blurayExaminer">Instance of the <see cref="IBlurayExaminer"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- /// <param name="encodingManager">Instance of the <see cref="IEncodingManager"/> interface.</param>
+ /// <param name="chapterManager">Instance of the <see cref="IChapterManager"/> interface.</param>
/// <param name="config">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="subtitleManager">Instance of the <see cref="ISubtitleManager"/> interface.</param>
- /// <param name="chapterManager">Instance of the <see cref="IChapterRepository"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/>.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
@@ -72,13 +70,11 @@ namespace MediaBrowser.Providers.MediaInfo
public ProbeProvider(
IMediaSourceManager mediaSourceManager,
IMediaEncoder mediaEncoder,
- IItemRepository itemRepo,
IBlurayExaminer blurayExaminer,
ILocalizationManager localization,
- IEncodingManager encodingManager,
+ IChapterManager chapterManager,
IServerConfigurationManager config,
ISubtitleManager subtitleManager,
- IChapterRepository chapterManager,
ILibraryManager libraryManager,
IFileSystem fileSystem,
ILoggerFactory loggerFactory,
@@ -96,13 +92,11 @@ namespace MediaBrowser.Providers.MediaInfo
loggerFactory.CreateLogger<FFProbeVideoInfo>(),
mediaSourceManager,
mediaEncoder,
- itemRepo,
blurayExaminer,
localization,
- encodingManager,
+ chapterManager,
config,
subtitleManager,
- chapterManager,
libraryManager,
_audioResolver,
_subtitleResolver,
@@ -113,7 +107,6 @@ namespace MediaBrowser.Providers.MediaInfo
loggerFactory.CreateLogger<AudioFileProber>(),
mediaSourceManager,
mediaEncoder,
- itemRepo,
libraryManager,
_lyricResolver,
lyricManager,
@@ -137,9 +130,9 @@ namespace MediaBrowser.Providers.MediaInfo
if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
{
var file = directoryService.GetFile(path);
- if (file is not null && file.LastWriteTimeUtc != item.DateModified)
+ if (file is not null && item.HasChanged(file.LastWriteTimeUtc))
{
- _logger.LogDebug("Refreshing {ItemPath} due to date modified timestamp change.", path);
+ _logger.LogDebug("Refreshing {ItemPath} due to file system modification.", path);
return true;
}
}
diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
index 938f3cb32..1134baf92 100644
--- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
+++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
@@ -14,7 +14,6 @@ using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Subtitles;
-using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Providers;
using MediaBrowser.Model.Tasks;
diff --git a/MediaBrowser.Providers/Movies/MovieMetadataService.cs b/MediaBrowser.Providers/Movies/MovieMetadataService.cs
index 8997ddc64..8c169a7b6 100644
--- a/MediaBrowser.Providers/Movies/MovieMetadataService.cs
+++ b/MediaBrowser.Providers/Movies/MovieMetadataService.cs
@@ -1,40 +1,54 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Movies
+namespace MediaBrowser.Providers.Movies;
+
+/// <summary>
+/// Service to manage movie metadata.
+/// </summary>
+public class MovieMetadataService : MetadataService<Movie, MovieInfo>
{
- public class MovieMetadataService : MetadataService<Movie, MovieInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MovieMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public MovieMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<MovieMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public MovieMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<MovieMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
+ }
- /// <inheritdoc />
- protected override void MergeData(MetadataResult<Movie> source, MetadataResult<Movie> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
- {
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ /// <inheritdoc />
+ protected override void MergeData(MetadataResult<Movie> source, MetadataResult<Movie> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
- var sourceItem = source.Item;
- var targetItem = target.Item;
+ var sourceItem = source.Item;
+ var targetItem = target.Item;
- if (replaceData || string.IsNullOrEmpty(targetItem.CollectionName))
- {
- targetItem.CollectionName = sourceItem.CollectionName;
- }
+ if (replaceData || string.IsNullOrEmpty(targetItem.CollectionName))
+ {
+ targetItem.CollectionName = sourceItem.CollectionName;
}
}
}
diff --git a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs
index e77d2fa8a..fa2442932 100644
--- a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs
+++ b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs
@@ -1,42 +1,56 @@
-#pragma warning disable CS1591
-
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Movies
+namespace MediaBrowser.Providers.Movies;
+
+/// <summary>
+/// Service to manage trailer metadata.
+/// </summary>
+public class TrailerMetadataService : MetadataService<Trailer, TrailerInfo>
{
- public class TrailerMetadataService : MetadataService<Trailer, TrailerInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="TrailerMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public TrailerMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<TrailerMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
+ {
+ }
+
+ /// <inheritdoc />
+ protected override void MergeData(MetadataResult<Trailer> source, MetadataResult<Trailer> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
- public TrailerMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<TrailerMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+
+ if (replaceData || target.Item.TrailerTypes.Length == 0)
{
+ target.Item.TrailerTypes = source.Item.TrailerTypes;
}
-
- /// <inheritdoc />
- protected override void MergeData(MetadataResult<Trailer> source, MetadataResult<Trailer> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ else
{
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
-
- if (replaceData || target.Item.TrailerTypes.Length == 0)
- {
- target.Item.TrailerTypes = source.Item.TrailerTypes;
- }
- else
- {
- target.Item.TrailerTypes = target.Item.TrailerTypes.Concat(source.Item.TrailerTypes).Distinct().ToArray();
- }
+ target.Item.TrailerTypes = target.Item.TrailerTypes.Concat(source.Item.TrailerTypes).Distinct().ToArray();
}
}
}
diff --git a/MediaBrowser.Providers/Music/AlbumMetadataService.cs b/MediaBrowser.Providers/Music/AlbumMetadataService.cs
index 0bcc301cb..13511b594 100644
--- a/MediaBrowser.Providers/Music/AlbumMetadataService.cs
+++ b/MediaBrowser.Providers/Music/AlbumMetadataService.cs
@@ -1,249 +1,266 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Music
+namespace MediaBrowser.Providers.Music;
+
+/// <summary>
+/// The album metadata service.
+/// </summary>
+public class AlbumMetadataService : MetadataService<MusicAlbum, AlbumInfo>
{
/// <summary>
- /// The album metadata service.
+ /// Initializes a new instance of the <see cref="AlbumMetadataService"/> class.
/// </summary>
- public class AlbumMetadataService : MetadataService<MusicAlbum, AlbumInfo>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public AlbumMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<AlbumMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- /// <summary>
- /// Initializes a new instance of the <see cref="AlbumMetadataService"/> class.
- /// </summary>
- /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
- /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
- /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- public AlbumMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<AlbumMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
+ }
+
+ /// <inheritdoc />
+ protected override bool EnableUpdatingPremiereDateFromChildren => true;
- /// <inheritdoc />
- protected override bool EnableUpdatingPremiereDateFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingGenresFromChildren => true;
- /// <inheritdoc />
- protected override bool EnableUpdatingGenresFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingStudiosFromChildren => true;
- /// <inheritdoc />
- protected override bool EnableUpdatingStudiosFromChildren => true;
+ /// <inheritdoc />
+ protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(MusicAlbum item)
+ => item.GetRecursiveChildren(i => i is Audio);
- /// <inheritdoc />
- protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(MusicAlbum item)
- => item.GetRecursiveChildren(i => i is Audio);
+ /// <inheritdoc />
+ protected override Task AfterMetadataRefresh(MusicAlbum item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
+ {
+ base.AfterMetadataRefresh(item, refreshOptions, cancellationToken);
+
+ SetPeople(item);
+
+ return Task.CompletedTask;
+ }
+
+ /// <inheritdoc />
+ protected override ItemUpdateType UpdateMetadataFromChildren(MusicAlbum item, IReadOnlyList<BaseItem> children, bool isFullRefresh, ItemUpdateType currentUpdateType)
+ {
+ var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType);
- /// <inheritdoc />
- protected override ItemUpdateType UpdateMetadataFromChildren(MusicAlbum item, IReadOnlyList<BaseItem> children, bool isFullRefresh, ItemUpdateType currentUpdateType)
+ // don't update user-changeable metadata for locked items
+ if (item.IsLocked)
{
- var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType);
+ return updateType;
+ }
- // don't update user-changeable metadata for locked items
- if (item.IsLocked)
+ if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
+ {
+ if (!item.LockedFields.Contains(MetadataField.Name))
{
- return updateType;
- }
+ var name = children.Select(i => i.Album).FirstOrDefault(i => !string.IsNullOrEmpty(i));
- if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
- {
- if (!item.LockedFields.Contains(MetadataField.Name))
+ if (!string.IsNullOrEmpty(name)
+ && !string.Equals(item.Name, name, StringComparison.Ordinal))
{
- var name = children.Select(i => i.Album).FirstOrDefault(i => !string.IsNullOrEmpty(i));
-
- if (!string.IsNullOrEmpty(name)
- && !string.Equals(item.Name, name, StringComparison.Ordinal))
- {
- item.Name = name;
- updateType |= ItemUpdateType.MetadataEdit;
- }
+ item.Name = name;
+ updateType |= ItemUpdateType.MetadataEdit;
}
-
- var songs = children.Cast<Audio>().ToArray();
-
- updateType |= SetArtistsFromSongs(item, songs);
- updateType |= SetAlbumArtistFromSongs(item, songs);
- updateType |= SetAlbumFromSongs(item, songs);
- updateType |= SetPeople(item);
}
- return updateType;
+ var songs = children.Cast<Audio>().ToArray();
+
+ updateType |= SetArtistsFromSongs(item, songs);
+ updateType |= SetAlbumArtistFromSongs(item, songs);
+ updateType |= SetAlbumFromSongs(item, songs);
}
- private ItemUpdateType SetAlbumArtistFromSongs(MusicAlbum item, IReadOnlyList<Audio> songs)
- {
- var updateType = ItemUpdateType.None;
+ return updateType;
+ }
- var albumArtists = songs
- .SelectMany(i => i.AlbumArtists)
- .GroupBy(i => i)
- .OrderByDescending(g => g.Count())
- .Select(g => g.Key)
- .ToArray();
+ private ItemUpdateType SetAlbumArtistFromSongs(MusicAlbum item, IReadOnlyList<Audio> songs)
+ {
+ var updateType = ItemUpdateType.None;
- updateType |= SetProviderIdFromSongs(item, songs, MetadataProvider.MusicBrainzAlbumArtist);
+ var albumArtists = songs
+ .SelectMany(i => i.AlbumArtists)
+ .GroupBy(i => i, StringComparer.OrdinalIgnoreCase)
+ .OrderByDescending(g => g.Count())
+ .Select(g => g.Key)
+ .ToArray();
- if (!item.AlbumArtists.SequenceEqual(albumArtists, StringComparer.OrdinalIgnoreCase))
- {
- item.AlbumArtists = albumArtists;
- updateType |= ItemUpdateType.MetadataEdit;
- }
+ updateType |= SetProviderIdFromSongs(item, songs, MetadataProvider.MusicBrainzAlbumArtist);
- return updateType;
+ if (!item.AlbumArtists.SequenceEqual(albumArtists, StringComparer.Ordinal))
+ {
+ item.AlbumArtists = albumArtists;
+ updateType |= ItemUpdateType.MetadataEdit;
}
- private ItemUpdateType SetArtistsFromSongs(MusicAlbum item, IReadOnlyList<Audio> songs)
- {
- var updateType = ItemUpdateType.None;
+ return updateType;
+ }
- var artists = songs
- .SelectMany(i => i.Artists)
- .GroupBy(i => i)
- .OrderByDescending(g => g.Count())
- .Select(g => g.Key)
- .ToArray();
+ private ItemUpdateType SetArtistsFromSongs(MusicAlbum item, IReadOnlyList<Audio> songs)
+ {
+ var updateType = ItemUpdateType.None;
- if (!item.Artists.SequenceEqual(artists, StringComparer.OrdinalIgnoreCase))
- {
- item.Artists = artists;
- updateType |= ItemUpdateType.MetadataEdit;
- }
+ var artists = songs
+ .SelectMany(i => i.Artists)
+ .GroupBy(i => i, StringComparer.OrdinalIgnoreCase)
+ .OrderByDescending(g => g.Count())
+ .Select(g => g.Key)
+ .ToArray();
- return updateType;
+ if (!item.Artists.SequenceEqual(artists, StringComparer.Ordinal))
+ {
+ item.Artists = artists;
+ updateType |= ItemUpdateType.MetadataEdit;
}
- private ItemUpdateType SetAlbumFromSongs(MusicAlbum item, IReadOnlyList<Audio> songs)
- {
- var updateType = ItemUpdateType.None;
+ return updateType;
+ }
- updateType |= SetProviderIdFromSongs(item, songs, MetadataProvider.MusicBrainzAlbum);
- updateType |= SetProviderIdFromSongs(item, songs, MetadataProvider.MusicBrainzReleaseGroup);
+ private ItemUpdateType SetAlbumFromSongs(MusicAlbum item, IReadOnlyList<Audio> songs)
+ {
+ var updateType = ItemUpdateType.None;
- return updateType;
- }
+ updateType |= SetProviderIdFromSongs(item, songs, MetadataProvider.MusicBrainzAlbum);
+ updateType |= SetProviderIdFromSongs(item, songs, MetadataProvider.MusicBrainzReleaseGroup);
- private ItemUpdateType SetProviderIdFromSongs(BaseItem item, IReadOnlyList<Audio> songs, MetadataProvider provider)
+ return updateType;
+ }
+
+ private ItemUpdateType SetProviderIdFromSongs(BaseItem item, IReadOnlyList<Audio> songs, MetadataProvider provider)
+ {
+ var ids = songs
+ .Select(i => i.GetProviderId(provider))
+ .GroupBy(i => i)
+ .OrderByDescending(g => g.Count())
+ .Select(g => g.Key)
+ .ToArray();
+
+ var id = item.GetProviderId(provider);
+ if (ids.Length != 0)
{
- var ids = songs
- .Select(i => i.GetProviderId(provider))
- .GroupBy(i => i)
- .OrderByDescending(g => g.Count())
- .Select(g => g.Key)
- .ToArray();
-
- var id = item.GetProviderId(provider);
- if (ids.Length != 0)
+ var firstId = ids[0];
+ if (!string.IsNullOrEmpty(firstId)
+ && (string.IsNullOrEmpty(id)
+ || !id.Equals(firstId, StringComparison.OrdinalIgnoreCase)))
{
- var firstId = ids[0];
- if (!string.IsNullOrEmpty(firstId)
- && (string.IsNullOrEmpty(id)
- || !id.Equals(firstId, StringComparison.OrdinalIgnoreCase)))
- {
- item.SetProviderId(provider, firstId);
- return ItemUpdateType.MetadataEdit;
- }
+ item.SetProviderId(provider, firstId);
+ return ItemUpdateType.MetadataEdit;
}
-
- return ItemUpdateType.None;
}
- private void SetProviderId(MusicAlbum sourceItem, MusicAlbum targetItem, MetadataProvider provider)
+ return ItemUpdateType.None;
+ }
+
+ private void SetProviderId(MusicAlbum sourceItem, MusicAlbum targetItem, MetadataProvider provider)
+ {
+ var source = sourceItem.GetProviderId(provider);
+ var target = targetItem.GetProviderId(provider);
+ if (!string.IsNullOrEmpty(source)
+ && (string.IsNullOrEmpty(target)
+ || !target.Equals(source, StringComparison.Ordinal)))
{
- var source = sourceItem.GetProviderId(provider);
- var target = targetItem.GetProviderId(provider);
- if (!string.IsNullOrEmpty(source)
- && (string.IsNullOrEmpty(target)
- || !target.Equals(source, StringComparison.Ordinal)))
- {
- targetItem.SetProviderId(provider, source);
- }
+ targetItem.SetProviderId(provider, source);
}
+ }
- private ItemUpdateType SetPeople(MusicAlbum item)
+ private void SetPeople(MusicAlbum item)
+ {
+ if (item.AlbumArtists.Any() || item.Artists.Any())
{
- var updateType = ItemUpdateType.None;
+ var people = new List<PersonInfo>();
- if (item.AlbumArtists.Any() || item.Artists.Any())
+ foreach (var albumArtist in item.AlbumArtists)
{
- var people = new List<PersonInfo>();
-
- foreach (var albumArtist in item.AlbumArtists)
+ if (!string.IsNullOrWhiteSpace(albumArtist))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
- Name = albumArtist.Trim(),
+ Name = albumArtist,
Type = PersonKind.AlbumArtist
});
}
+ }
- foreach (var artist in item.Artists)
+ foreach (var artist in item.Artists)
+ {
+ if (!string.IsNullOrWhiteSpace(artist))
{
PeopleHelper.AddPerson(people, new PersonInfo
{
- Name = artist.Trim(),
+ Name = artist,
Type = PersonKind.Artist
});
}
-
- LibraryManager.UpdatePeople(item, people);
- updateType |= ItemUpdateType.MetadataEdit;
}
- return updateType;
+ LibraryManager.UpdatePeople(item, people);
}
+ }
- /// <inheritdoc />
- protected override void MergeData(
- MetadataResult<MusicAlbum> source,
- MetadataResult<MusicAlbum> target,
- MetadataField[] lockedFields,
- bool replaceData,
- bool mergeMetadataSettings)
- {
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ /// <inheritdoc />
+ protected override void MergeData(
+ MetadataResult<MusicAlbum> source,
+ MetadataResult<MusicAlbum> target,
+ MetadataField[] lockedFields,
+ bool replaceData,
+ bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
- var sourceItem = source.Item;
- var targetItem = target.Item;
+ var sourceItem = source.Item;
+ var targetItem = target.Item;
- if (replaceData || targetItem.Artists.Count == 0)
- {
- targetItem.Artists = sourceItem.Artists;
- }
- else
- {
- targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
- }
+ if (replaceData || targetItem.Artists.Count == 0)
+ {
+ targetItem.Artists = sourceItem.Artists;
+ }
+ else
+ {
+ targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ }
- if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
- {
- SetProviderId(sourceItem, targetItem, MetadataProvider.MusicBrainzAlbumArtist);
- }
+ if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
+ {
+ SetProviderId(sourceItem, targetItem, MetadataProvider.MusicBrainzAlbumArtist);
+ }
- if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbum)))
- {
- SetProviderId(sourceItem, targetItem, MetadataProvider.MusicBrainzAlbum);
- }
+ if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbum)))
+ {
+ SetProviderId(sourceItem, targetItem, MetadataProvider.MusicBrainzAlbum);
+ }
- if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)))
- {
- SetProviderId(sourceItem, targetItem, MetadataProvider.MusicBrainzReleaseGroup);
- }
+ if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)))
+ {
+ SetProviderId(sourceItem, targetItem, MetadataProvider.MusicBrainzReleaseGroup);
}
}
}
diff --git a/MediaBrowser.Providers/Music/ArtistMetadataService.cs b/MediaBrowser.Providers/Music/ArtistMetadataService.cs
index c47f9a500..22999077b 100644
--- a/MediaBrowser.Providers/Music/ArtistMetadataService.cs
+++ b/MediaBrowser.Providers/Music/ArtistMetadataService.cs
@@ -1,43 +1,56 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
-using System.Collections.Immutable;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Music
+namespace MediaBrowser.Providers.Music;
+
+/// <summary>
+/// Service to manage artist metadata.
+/// </summary>
+public class ArtistMetadataService : MetadataService<MusicArtist, ArtistInfo>
{
- public class ArtistMetadataService : MetadataService<MusicArtist, ArtistInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ArtistMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public ArtistMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<ArtistMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public ArtistMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<ArtistMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
+ }
- /// <inheritdoc />
- protected override bool EnableUpdatingGenresFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingGenresFromChildren => true;
- /// <inheritdoc />
- protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(MusicArtist item)
- {
- return item.IsAccessedByName
- ? item.GetTaggedItems(new InternalItemsQuery
- {
- Recursive = true,
- IsFolder = false
- })
- : item.GetRecursiveChildren(i => i is IHasArtist && !i.IsFolder);
- }
+ /// <inheritdoc />
+ protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(MusicArtist item)
+ {
+ return item.IsAccessedByName
+ ? item.GetTaggedItems(new InternalItemsQuery
+ {
+ Recursive = true,
+ IsFolder = false
+ })
+ : item.GetRecursiveChildren(i => i is IHasArtist && !i.IsFolder);
}
}
diff --git a/MediaBrowser.Providers/Music/AudioMetadataService.cs b/MediaBrowser.Providers/Music/AudioMetadataService.cs
index 7b25bc0e4..f4d17686f 100644
--- a/MediaBrowser.Providers/Music/AudioMetadataService.cs
+++ b/MediaBrowser.Providers/Music/AudioMetadataService.cs
@@ -2,78 +2,83 @@ using System;
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Music
+namespace MediaBrowser.Providers.Music;
+
+/// <summary>
+/// The audio metadata service.
+/// </summary>
+public class AudioMetadataService : MetadataService<Audio, SongInfo>
{
/// <summary>
- /// The audio metadata service.
+ /// Initializes a new instance of the <see cref="AudioMetadataService"/> class.
/// </summary>
- public class AudioMetadataService : MetadataService<Audio, SongInfo>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public AudioMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<AudioMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- /// <summary>
- /// Initializes a new instance of the <see cref="AudioMetadataService"/> class.
- /// </summary>
- /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
- /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
- /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- public AudioMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<AudioMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
+ }
- private void SetProviderId(Audio sourceItem, Audio targetItem, bool replaceData, MetadataProvider provider)
+ private void SetProviderId(Audio sourceItem, Audio targetItem, bool replaceData, MetadataProvider provider)
+ {
+ var target = targetItem.GetProviderId(provider);
+ if (replaceData || string.IsNullOrEmpty(target))
{
- var target = targetItem.GetProviderId(provider);
- if (replaceData || string.IsNullOrEmpty(target))
+ var source = sourceItem.GetProviderId(provider);
+ if (!string.IsNullOrEmpty(source)
+ && (string.IsNullOrEmpty(target)
+ || !target.Equals(source, StringComparison.Ordinal)))
{
- var source = sourceItem.GetProviderId(provider);
- if (!string.IsNullOrEmpty(source)
- && (string.IsNullOrEmpty(target)
- || !target.Equals(source, StringComparison.Ordinal)))
- {
- targetItem.SetProviderId(provider, source);
- }
+ targetItem.SetProviderId(provider, source);
}
}
+ }
- /// <inheritdoc />
- protected override void MergeData(MetadataResult<Audio> source, MetadataResult<Audio> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
- {
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ /// <inheritdoc />
+ protected override void MergeData(MetadataResult<Audio> source, MetadataResult<Audio> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
- var sourceItem = source.Item;
- var targetItem = target.Item;
+ var sourceItem = source.Item;
+ var targetItem = target.Item;
- if (replaceData || targetItem.Artists.Count == 0)
- {
- targetItem.Artists = sourceItem.Artists;
- }
- else
- {
- targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
- }
-
- if (replaceData || string.IsNullOrEmpty(targetItem.Album))
- {
- targetItem.Album = sourceItem.Album;
- }
+ if (replaceData || targetItem.Artists.Count == 0)
+ {
+ targetItem.Artists = sourceItem.Artists;
+ }
+ else
+ {
+ targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ }
- SetProviderId(sourceItem, targetItem, replaceData, MetadataProvider.MusicBrainzAlbumArtist);
- SetProviderId(sourceItem, targetItem, replaceData, MetadataProvider.MusicBrainzAlbum);
- SetProviderId(sourceItem, targetItem, replaceData, MetadataProvider.MusicBrainzReleaseGroup);
+ if (replaceData || string.IsNullOrEmpty(targetItem.Album))
+ {
+ targetItem.Album = sourceItem.Album;
}
+
+ SetProviderId(sourceItem, targetItem, replaceData, MetadataProvider.MusicBrainzAlbumArtist);
+ SetProviderId(sourceItem, targetItem, replaceData, MetadataProvider.MusicBrainzAlbum);
+ SetProviderId(sourceItem, targetItem, replaceData, MetadataProvider.MusicBrainzReleaseGroup);
}
}
diff --git a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs
index 24c4b5501..345e13460 100644
--- a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs
+++ b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs
@@ -1,55 +1,70 @@
-#pragma warning disable CS1591
-
+using System;
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Music
+namespace MediaBrowser.Providers.Music;
+
+/// <summary>
+/// Service to manage music video metadata.
+/// </summary>
+public class MusicVideoMetadataService : MetadataService<MusicVideo, MusicVideoInfo>
{
- public class MusicVideoMetadataService : MetadataService<MusicVideo, MusicVideoInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MusicVideoMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public MusicVideoMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<MusicVideoMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public MusicVideoMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<MusicVideoMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
+ }
- /// <inheritdoc />
- protected override void MergeData(
- MetadataResult<MusicVideo> source,
- MetadataResult<MusicVideo> target,
- MetadataField[] lockedFields,
- bool replaceData,
- bool mergeMetadataSettings)
- {
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ /// <inheritdoc />
+ protected override void MergeData(
+ MetadataResult<MusicVideo> source,
+ MetadataResult<MusicVideo> target,
+ MetadataField[] lockedFields,
+ bool replaceData,
+ bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
- var sourceItem = source.Item;
- var targetItem = target.Item;
+ var sourceItem = source.Item;
+ var targetItem = target.Item;
- if (replaceData || string.IsNullOrEmpty(targetItem.Album))
- {
- targetItem.Album = sourceItem.Album;
- }
+ if (replaceData || string.IsNullOrEmpty(targetItem.Album))
+ {
+ targetItem.Album = sourceItem.Album;
+ }
- if (replaceData || targetItem.Artists.Count == 0)
- {
- targetItem.Artists = sourceItem.Artists;
- }
- else
- {
- targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
- }
+ if (replaceData || targetItem.Artists.Count == 0)
+ {
+ targetItem.Artists = sourceItem.Artists;
+ }
+ else
+ {
+ targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}
}
diff --git a/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs b/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs
index 46eb546c2..4b0044dcf 100644
--- a/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs
+++ b/MediaBrowser.Providers/MusicGenres/MusicGenreMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.MusicGenres
+namespace MediaBrowser.Providers.MusicGenres;
+
+/// <summary>
+/// Service to manage music genre metadata.
+/// </summary>
+public class MusicGenreMetadataService : MetadataService<MusicGenre, ItemLookupInfo>
{
- public class MusicGenreMetadataService : MetadataService<MusicGenre, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MusicGenreMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public MusicGenreMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<MusicGenreMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public MusicGenreMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<MusicGenreMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/People/PersonMetadataService.cs b/MediaBrowser.Providers/People/PersonMetadataService.cs
index 59bf7e4e6..23aff246e 100644
--- a/MediaBrowser.Providers/People/PersonMetadataService.cs
+++ b/MediaBrowser.Providers/People/PersonMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.People
+namespace MediaBrowser.Providers.People;
+
+/// <summary>
+/// Service to manage person metadata.
+/// </summary>
+public class PersonMetadataService : MetadataService<Person, PersonLookupInfo>
{
- public class PersonMetadataService : MetadataService<Person, PersonLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PersonMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public PersonMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<PersonMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public PersonMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<PersonMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs b/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs
index f2cccb90f..f05ed904f 100644
--- a/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs
+++ b/MediaBrowser.Providers/Photos/PhotoAlbumMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Photos
+namespace MediaBrowser.Providers.Photos;
+
+/// <summary>
+/// Service to manage photo album metadata.
+/// </summary>
+public class PhotoAlbumMetadataService : MetadataService<PhotoAlbum, ItemLookupInfo>
{
- public class PhotoAlbumMetadataService : MetadataService<PhotoAlbum, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PhotoAlbumMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public PhotoAlbumMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<PhotoAlbumMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public PhotoAlbumMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<PhotoAlbumMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/Photos/PhotoMetadataService.cs b/MediaBrowser.Providers/Photos/PhotoMetadataService.cs
index 6941401e0..0f7a33560 100644
--- a/MediaBrowser.Providers/Photos/PhotoMetadataService.cs
+++ b/MediaBrowser.Providers/Photos/PhotoMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Photos
+namespace MediaBrowser.Providers.Photos;
+
+/// <summary>
+/// Service to manage photo metadata.
+/// </summary>
+public class PhotoMetadataService : MetadataService<Photo, ItemLookupInfo>
{
- public class PhotoMetadataService : MetadataService<Photo, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PhotoMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public PhotoMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<PhotoMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public PhotoMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<PhotoMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
index 51a3ba0c7..4c10fe3f1 100644
--- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
+++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
@@ -99,7 +99,7 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
.OfType<CollectionFolder>()
.Where(f => f.CollectionType.HasValue && !_ignoredCollections.Contains(f.CollectionType.Value))
.SelectMany(f => f.PhysicalLocations)
- .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Distinct()
.ToList();
using (var stream = File.OpenRead(path))
@@ -215,7 +215,7 @@ public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
{
var file = directoryService.GetFile(path);
- if (file is not null && file.LastWriteTimeUtc != item.DateModified)
+ if (file is not null && item.HasChanged(file.LastWriteTimeUtc))
{
_logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path);
return true;
diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
index 7be54453f..8df15e440 100644
--- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
+++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
@@ -1,10 +1,10 @@
-#pragma warning disable CS1591
-
using System.Collections.Generic;
using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -12,62 +12,76 @@ using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Playlists
+namespace MediaBrowser.Providers.Playlists;
+
+/// <summary>
+/// Service to manage playlist metadata.
+/// </summary>
+public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
{
- public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlaylistMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public PlaylistMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<PlaylistMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public PlaylistMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<PlaylistMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
+ }
- /// <inheritdoc />
- protected override bool EnableUpdatingGenresFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingGenresFromChildren => true;
- /// <inheritdoc />
- protected override bool EnableUpdatingOfficialRatingFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingOfficialRatingFromChildren => true;
- /// <inheritdoc />
- protected override bool EnableUpdatingStudiosFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingStudiosFromChildren => true;
- /// <inheritdoc />
- protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(Playlist item)
- => item.GetLinkedChildren();
+ /// <inheritdoc />
+ protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(Playlist item)
+ => item.GetLinkedChildren();
- /// <inheritdoc />
- protected override void MergeData(MetadataResult<Playlist> source, MetadataResult<Playlist> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
- {
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ /// <inheritdoc />
+ protected override void MergeData(MetadataResult<Playlist> source, MetadataResult<Playlist> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
- var sourceItem = source.Item;
- var targetItem = target.Item;
+ var sourceItem = source.Item;
+ var targetItem = target.Item;
- if (mergeMetadataSettings)
- {
- targetItem.PlaylistMediaType = sourceItem.PlaylistMediaType;
+ if (mergeMetadataSettings)
+ {
+ targetItem.PlaylistMediaType = sourceItem.PlaylistMediaType;
- if (replaceData || targetItem.LinkedChildren.Length == 0)
- {
- targetItem.LinkedChildren = sourceItem.LinkedChildren;
- }
- else
- {
- targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
- }
+ if (replaceData || targetItem.LinkedChildren.Length == 0)
+ {
+ targetItem.LinkedChildren = sourceItem.LinkedChildren;
+ }
+ else
+ {
+ targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
+ }
- if (replaceData || targetItem.Shares.Count == 0)
- {
- targetItem.Shares = sourceItem.Shares;
- }
- else
- {
- targetItem.Shares = sourceItem.Shares.Concat(targetItem.Shares).DistinctBy(s => s.UserId).ToArray();
- }
+ if (replaceData || targetItem.Shares.Count == 0)
+ {
+ targetItem.Shares = sourceItem.Shares;
+ }
+ else
+ {
+ targetItem.Shares = sourceItem.Shares.Concat(targetItem.Shares).DistinctBy(s => s.UserId).ToArray();
}
}
}
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
index ff30af879..49ece22a9 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs
@@ -94,7 +94,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
if (!string.IsNullOrWhiteSpace(result.strArtist))
{
- item.AlbumArtists = new string[] { result.strArtist };
+ item.AlbumArtists = [result.strArtist];
}
if (!string.IsNullOrEmpty(result.intYearReleased))
@@ -104,7 +104,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
if (!string.IsNullOrEmpty(result.strGenre))
{
- item.Genres = new[] { result.strGenre };
+ item.Genres = [result.strGenre];
}
item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist);
@@ -170,6 +170,11 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
var url = AudioDbArtistProvider.BaseUrl + "/album-mb.php?i=" + musicBrainzReleaseGroupId;
var path = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId);
+ var fileInfo = _fileSystem.GetFileSystemInfo(path);
+ if (fileInfo.Exists && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2)
+ {
+ return;
+ }
Directory.CreateDirectory(Path.GetDirectoryName(path));
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalUrlProvider.cs
index 56b0d9bcb..6c1fbbeb7 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalUrlProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalUrlProvider.cs
@@ -22,7 +22,7 @@ public class AudioDbArtistExternalUrlProvider : IExternalUrlProvider
var baseUrl = "https://www.theaudiodb.com/";
switch (item)
{
- case MusicAlbum:
+ case MusicArtist:
case Person:
yield return baseUrl + $"artist/{externalId}";
break;
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalUrlProvider.cs
index ee5a597c6..398ec2d20 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalUrlProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalUrlProvider.cs
@@ -21,7 +21,7 @@ public class MusicBrainzArtistExternalUrlProvider : IExternalUrlProvider
{
switch (item)
{
- case MusicAlbum:
+ case MusicArtist:
case Person:
yield return Plugin.Instance!.Configuration.Server + $"/artist/{externalId}";
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index ad9edb031..82c6e3011 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
@@ -138,6 +138,8 @@ namespace MediaBrowser.Providers.Plugins.Omdb
}
var item = itemResult.Item;
+ item.IndexNumber = episodeNumber;
+ item.ParentIndexNumber = seasonNumber;
var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false);
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
index 18cdba7a0..02818a0e2 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs
@@ -66,7 +66,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
var language = item.GetPreferredMetadataLanguage();
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
- var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, null, null, cancellationToken).ConfigureAwait(false);
+ var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, null, null, null, cancellationToken).ConfigureAwait(false);
if (collection?.Images is null)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
index c76c65591..34c9abae1 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs
@@ -47,7 +47,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
if (tmdbId > 0)
{
- var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken).ConfigureAwait(false);
+ var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language, searchInfo.MetadataCountryCode), searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (collection is null)
{
@@ -70,7 +70,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
return new[] { result };
}
- var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, cancellationToken).ConfigureAwait(false);
+ var collectionSearchResults = await _tmdbClientManager.SearchCollectionAsync(searchInfo.Name, language, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
var collections = new RemoteSearchResult[collectionSearchResults.Count];
for (var i = 0; i < collectionSearchResults.Count; i++)
@@ -95,6 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
{
var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
var language = info.MetadataLanguage;
+
// We don't already have an Id, need to fetch it
if (tmdbId <= 0)
{
@@ -102,7 +103,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
// 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.SearchCollectionAsync(cleanedName, language, cancellationToken).ConfigureAwait(false);
+ var searchResults = await _tmdbClientManager.SearchCollectionAsync(cleanedName, language, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (searchResults is not null && searchResults.Count > 0)
{
@@ -114,7 +115,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets
if (tmdbId > 0)
{
- var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language), cancellationToken).ConfigureAwait(false);
+ var collection = await _tmdbClientManager.GetCollectionAsync(tmdbId, language, TmdbUtils.GetImageLanguagesParam(language, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (collection is not null)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs
index 99b759ae2..f11b1d95a 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/PluginConfiguration.cs
@@ -39,6 +39,21 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
public int MaxCastMembers { get; set; } = 15;
/// <summary>
+ /// Gets or sets a value indicating the maximum number of crew members to fetch for an item.
+ /// </summary>
+ public int MaxCrewMembers { get; set; } = 15;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to hide cast members without profile images.
+ /// </summary>
+ public bool HideMissingCastMembers { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to hide crew members without profile images.
+ /// </summary>
+ public bool HideMissingCrewMembers { get; set; }
+
+ /// <summary>
/// Gets or sets a value indicating the poster image size to fetch.
/// </summary>
public string? PosterSize { get; set; }
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html
index f3c24e7b4..89d380ec1 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Configuration/config.html
@@ -25,9 +25,24 @@
<input is="emby-checkbox" type="checkbox" id="importSeasonName" />
<span>Import season name from metadata fetched for series.</span>
</label>
- <div class="inputContainer">
- <input is="emby-input" type="number" id="maxCastMembers" pattern="[0-9]*" required min="0" max="1000" label="Max Cast Members" />
- <div class="fieldDescription">The maximum number of cast members to fetch for an item.</div>
+ <div class="verticalSection">
+ <h2>Cast & Crew Settings</h2>
+ <div class="inputContainer">
+ <input is="emby-input" type="number" id="maxCastMembers" pattern="[0-9]*" required min="0" max="1000" label="Max Cast Members" />
+ <div class="fieldDescription">The maximum number of cast members to fetch for an item.</div>
+ </div>
+ <div class="inputContainer">
+ <input is="emby-input" type="number" id="maxCrewMembers" pattern="[0-9]*" required min="0" max="1000" label="Max Crew Members" />
+ <div class="fieldDescription">The maximum number of crew members to fetch for an item.</div>
+ </div>
+ <label class="checkboxContainer">
+ <input is="emby-checkbox" type="checkbox" id="hideMissingCastMembers" />
+ <span>Hide cast members without profile images.</span>
+ </label>
+ <label class="checkboxContainer">
+ <input is="emby-checkbox" type="checkbox" id="hideMissingCrewMembers" />
+ <span>Hide crew members without profile images.</span>
+ </label>
</div>
<div class="verticalSection verticalSection-extrabottompadding">
<h2>Image Scaling</h2>
@@ -129,6 +144,8 @@
document.querySelector('#excludeTagsSeries').checked = config.ExcludeTagsSeries;
document.querySelector('#excludeTagsMovies').checked = config.ExcludeTagsMovies;
document.querySelector('#importSeasonName').checked = config.ImportSeasonName;
+ document.querySelector('#hideMissingCastMembers').checked = config.HideMissingCastMembers;
+ document.querySelector('#hideMissingCrewMembers').checked = config.HideMissingCrewMembers;
var maxCastMembers = document.querySelector('#maxCastMembers');
maxCastMembers.value = config.MaxCastMembers;
@@ -137,12 +154,18 @@
cancelable: false
}));
+ var maxCrewMembers = document.querySelector('#maxCrewMembers');
+ maxCrewMembers.value = config.MaxCrewMembers;
+ maxCrewMembers.dispatchEvent(new Event('change', {
+ bubbles: true,
+ cancelable: false
+ }));
+
pluginConfig = config;
configureImageScaling();
});
});
-
document.querySelector('.configForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
@@ -153,6 +176,9 @@
config.ExcludeTagsMovies = document.querySelector('#excludeTagsMovies').checked;
config.ImportSeasonName = document.querySelector('#importSeasonName').checked;
config.MaxCastMembers = document.querySelector('#maxCastMembers').value;
+ config.MaxCrewMembers = document.querySelector('#maxCrewMembers').value;
+ config.HideMissingCastMembers = document.querySelector('#hideMissingCastMembers').checked;
+ config.HideMissingCrewMembers = document.querySelector('#hideMissingCrewMembers').checked;
config.PosterSize = document.querySelector('#selectPosterSize').value;
config.BackdropSize = document.querySelector('#selectBackdropSize').value;
config.LogoSize = document.querySelector('#selectLogoSize').value;
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
index 1696a2c49..fcc357410 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs
@@ -59,6 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
var language = item.GetPreferredMetadataLanguage();
+ var countryCode = item.GetPreferredMetadataCountryCode();
var movieTmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture);
if (movieTmdbId <= 0)
@@ -69,7 +70,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
return Enumerable.Empty<RemoteImageInfo>();
}
- var movieResult = await _tmdbClientManager.FindByExternalIdAsync(movieImdbId, FindExternalSource.Imdb, language, cancellationToken).ConfigureAwait(false);
+ var movieResult = await _tmdbClientManager.FindByExternalIdAsync(movieImdbId, FindExternalSource.Imdb, language, countryCode, cancellationToken).ConfigureAwait(false);
if (movieResult?.MovieResults is not null && movieResult.MovieResults.Count > 0)
{
movieTmdbId = movieResult.MovieResults[0].Id;
@@ -83,7 +84,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
var movie = await _tmdbClientManager
- .GetMovieAsync(movieTmdbId, null, null, cancellationToken)
+ .GetMovieAsync(movieTmdbId, null, null, null, cancellationToken)
.ConfigureAwait(false);
if (movie?.Images is null)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
index 9bb6507fe..414a0a3c9 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
@@ -59,7 +59,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
.GetMovieAsync(
int.Parse(id, CultureInfo.InvariantCulture),
searchInfo.MetadataLanguage,
- TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage),
+ TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode),
+ searchInfo.MetadataCountryCode,
cancellationToken)
.ConfigureAwait(false);
@@ -93,7 +94,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var result = await _tmdbClientManager.FindByExternalIdAsync(
id,
FindExternalSource.Imdb,
- TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage),
+ TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode),
+ searchInfo.MetadataCountryCode,
cancellationToken).ConfigureAwait(false);
movieResults = result?.MovieResults;
}
@@ -103,7 +105,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var result = await _tmdbClientManager.FindByExternalIdAsync(
id,
FindExternalSource.TvDb,
- TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage),
+ TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode),
+ searchInfo.MetadataCountryCode,
cancellationToken).ConfigureAwait(false);
movieResults = result?.MovieResults;
}
@@ -111,7 +114,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
if (movieResults is null)
{
movieResults = await _tmdbClientManager
- .SearchMovieAsync(searchInfo.Name, searchInfo.Year ?? 0, searchInfo.MetadataLanguage, cancellationToken)
+ .SearchMovieAsync(searchInfo.Name, searchInfo.Year ?? 0, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
}
@@ -144,6 +147,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
{
var tmdbId = info.GetProviderId(MetadataProvider.Tmdb);
var imdbId = info.GetProviderId(MetadataProvider.Imdb);
+ var config = Plugin.Instance.Configuration;
if (string.IsNullOrEmpty(tmdbId) && string.IsNullOrEmpty(imdbId))
{
@@ -151,7 +155,8 @@ 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 searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
+
+ var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (searchResults.Count > 0)
{
@@ -161,7 +166,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId))
{
- var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
+ var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (movieResultFromImdbId?.MovieResults.Count > 0)
{
tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture);
@@ -174,7 +179,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
}
var movieResult = await _tmdbClientManager
- .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken)
+ .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (movieResult is null)
@@ -212,15 +217,18 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var releases = movieResult.Releases.Countries.Where(i => !string.IsNullOrWhiteSpace(i.Certification)).ToList();
var ourRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, info.MetadataCountryCode, StringComparison.OrdinalIgnoreCase));
- var usRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase));
if (ourRelease is not null)
{
movie.OfficialRating = TmdbUtils.BuildParentalRating(ourRelease.Iso_3166_1, ourRelease.Certification);
}
- else if (usRelease is not null)
+ else
{
- movie.OfficialRating = usRelease.Certification;
+ var usRelease = releases.FirstOrDefault(c => string.Equals(c.Iso_3166_1, "US", StringComparison.OrdinalIgnoreCase));
+ if (usRelease is not null)
+ {
+ movie.OfficialRating = usRelease.Certification;
+ }
}
}
@@ -249,12 +257,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
if (movieResult.Credits?.Cast is not null)
{
- foreach (var actor in movieResult.Credits.Cast.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers))
+ var castQuery = movieResult.Credits.Cast.AsEnumerable();
+
+ if (config.HideMissingCastMembers)
+ {
+ castQuery = castQuery.Where(a => !string.IsNullOrEmpty(a.ProfilePath));
+ }
+
+ castQuery = castQuery.OrderBy(a => a.Order).Take(config.MaxCastMembers);
+
+ foreach (var actor in castQuery)
{
+ if (string.IsNullOrWhiteSpace(actor.Name))
+ {
+ continue;
+ }
+
var personInfo = new PersonInfo
{
Name = actor.Name.Trim(),
- Role = actor.Character.Trim(),
+ Role = actor.Character?.Trim() ?? string.Empty,
Type = PersonKind.Actor,
SortOrder = actor.Order
};
@@ -275,32 +297,47 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
if (movieResult.Credits?.Crew is not null)
{
- foreach (var person in movieResult.Credits.Crew)
+ var crewQuery = movieResult.Credits.Crew
+ .Select(crewMember => new
+ {
+ CrewMember = crewMember,
+ PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
+ })
+ .Where(entry =>
+ TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
+ TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
+
+ if (config.HideMissingCrewMembers)
{
- // Normalize this
- var type = TmdbUtils.MapCrewToPersonType(person);
+ crewQuery = crewQuery.Where(entry => !string.IsNullOrEmpty(entry.CrewMember.ProfilePath));
+ }
+
+ crewQuery = crewQuery.Take(config.MaxCrewMembers);
- if (!TmdbUtils.WantedCrewKinds.Contains(type)
- && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase))
+ foreach (var entry in crewQuery)
+ {
+ var crewMember = entry.CrewMember;
+
+ if (string.IsNullOrWhiteSpace(crewMember.Name))
{
continue;
}
var personInfo = new PersonInfo
{
- Name = person.Name.Trim(),
- Role = person.Job?.Trim(),
- Type = type
+ Name = crewMember.Name.Trim(),
+ Role = crewMember.Job?.Trim() ?? string.Empty,
+ Type = entry.PersonType
};
- if (!string.IsNullOrWhiteSpace(person.ProfilePath))
+ if (!string.IsNullOrWhiteSpace(crewMember.ProfilePath))
{
- personInfo.ImageUrl = _tmdbClientManager.GetProfileUrl(person.ProfilePath);
+ personInfo.ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath);
}
- if (person.Id > 0)
+ if (crewMember.Id > 0)
{
- personInfo.SetProviderId(MetadataProvider.Tmdb, person.Id.ToString(CultureInfo.InvariantCulture));
+ personInfo.SetProviderId(MetadataProvider.Tmdb, crewMember.Id.ToString(CultureInfo.InvariantCulture));
}
metadataResult.AddPerson(personInfo);
@@ -310,9 +347,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
if (movieResult.Videos?.Results is not null)
{
var trailers = new List<MediaUrl>();
- for (var i = 0; i < movieResult.Videos.Results.Count; i++)
+
+ var sortedVideos = movieResult.Videos.Results
+ .OrderByDescending(video => string.Equals(video.Type, "trailer", StringComparison.OrdinalIgnoreCase));
+
+ foreach (var video in sortedVideos)
{
- var video = movieResult.Videos.Results[i];
if (!TmdbUtils.IsTrailerType(video))
{
continue;
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
index 9e5404b32..33888ddf4 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs
@@ -60,7 +60,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
}
var language = item.GetPreferredMetadataLanguage();
- var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, cancellationToken).ConfigureAwait(false);
+ var countryCode = item.GetPreferredMetadataCountryCode();
+ var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, countryCode, cancellationToken).ConfigureAwait(false);
if (personResult?.Images?.Profiles is null)
{
return Enumerable.Empty<RemoteImageInfo>();
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
index 98c46895d..4b32d0f6b 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs
@@ -39,7 +39,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
{
if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId))
{
- var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false);
+ var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (personResult is not null)
{
@@ -101,7 +101,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People
if (personTmdbId > 0)
{
- var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
+ var person = await _tmdbClientManager.GetPersonAsync(personTmdbId, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (person is null)
{
return result;
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
index 7de0e430f..7ae54cdcd 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs
@@ -75,7 +75,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
var episodeResult = await _tmdbClientManager
- .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, series.DisplayOrder, null, null, cancellationToken)
+ .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, series.DisplayOrder, null, null, null, cancellationToken)
.ConfigureAwait(false);
var stills = episodeResult?.Images?.Stills;
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
index 73c3b4f16..e30c555cb 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs
@@ -81,6 +81,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken)
{
var metadataResult = new MetadataResult<Episode>();
+ var config = Plugin.Instance.Configuration;
// Allowing this will dramatically increase scan times
if (info.IsMissingEpisode)
@@ -112,7 +113,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
List<TvEpisode>? result = null;
for (int? episode = startindex; episode <= endindex; episode++)
{
- var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber, episode.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken).ConfigureAwait(false);
+ var episodeInfo = await _tmdbClientManager.GetEpisodeAsync(seriesTmdbId, seasonNumber, episode.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (episodeInfo is not null)
{
(result ??= new List<TvEpisode>()).Add(episodeInfo);
@@ -156,7 +157,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
else
{
episodeResult = await _tmdbClientManager
- .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken)
+ .GetEpisodeAsync(seriesTmdbId, seasonNumber, episodeNumber.Value, info.SeriesDisplayOrder, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
}
@@ -176,8 +177,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
var item = new Episode
{
- IndexNumber = info.IndexNumber,
- ParentIndexNumber = info.ParentIndexNumber,
+ IndexNumber = episodeNumber,
+ ParentIndexNumber = seasonNumber,
IndexNumberEnd = info.IndexNumberEnd,
Name = episodeResult.Name,
PremiereDate = episodeResult.AirDate,
@@ -206,52 +207,106 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
if (credits?.Cast is not null)
{
- foreach (var actor in credits.Cast.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers))
+ var castQuery = config.HideMissingCastMembers
+ ? credits.Cast.Where(a => !string.IsNullOrEmpty(a.ProfilePath)).OrderBy(a => a.Order)
+ : credits.Cast.OrderBy(a => a.Order);
+
+ foreach (var actor in castQuery.Take(config.MaxCastMembers))
{
- metadataResult.AddPerson(new PersonInfo
+ if (string.IsNullOrWhiteSpace(actor.Name))
+ {
+ continue;
+ }
+
+ var personInfo = new PersonInfo
{
Name = actor.Name.Trim(),
- Role = actor.Character.Trim(),
+ Role = actor.Character?.Trim() ?? string.Empty,
Type = PersonKind.Actor,
- SortOrder = actor.Order
- });
+ SortOrder = actor.Order,
+ ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath)
+ };
+
+ if (actor.Id > 0)
+ {
+ personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture));
+ }
+
+ metadataResult.AddPerson(personInfo);
}
}
if (credits?.GuestStars is not null)
{
- foreach (var guest in credits.GuestStars.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers))
+ var guestQuery = config.HideMissingCastMembers
+ ? credits.GuestStars.Where(a => !string.IsNullOrEmpty(a.ProfilePath)).OrderBy(a => a.Order)
+ : credits.GuestStars.OrderBy(a => a.Order);
+
+ foreach (var guest in guestQuery.Take(config.MaxCastMembers))
{
- metadataResult.AddPerson(new PersonInfo
+ if (string.IsNullOrWhiteSpace(guest.Name))
+ {
+ continue;
+ }
+
+ var personInfo = new PersonInfo
{
Name = guest.Name.Trim(),
- Role = guest.Character.Trim(),
+ Role = guest.Character?.Trim() ?? string.Empty,
Type = PersonKind.GuestStar,
- SortOrder = guest.Order
- });
+ SortOrder = guest.Order,
+ ImageUrl = _tmdbClientManager.GetProfileUrl(guest.ProfilePath)
+ };
+
+ if (guest.Id > 0)
+ {
+ personInfo.SetProviderId(MetadataProvider.Tmdb, guest.Id.ToString(CultureInfo.InvariantCulture));
+ }
+
+ metadataResult.AddPerson(personInfo);
}
}
- // and the rest from crew
if (credits?.Crew is not null)
{
- foreach (var person in credits.Crew)
+ var crewQuery = credits.Crew
+ .Select(crewMember => new
+ {
+ CrewMember = crewMember,
+ PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
+ })
+ .Where(entry =>
+ TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
+ TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
+
+ if (config.HideMissingCrewMembers)
+ {
+ crewQuery = crewQuery.Where(entry => !string.IsNullOrEmpty(entry.CrewMember.ProfilePath));
+ }
+
+ foreach (var entry in crewQuery.Take(config.MaxCrewMembers))
{
- // Normalize this
- var type = TmdbUtils.MapCrewToPersonType(person);
+ var crewMember = entry.CrewMember;
- if (!TmdbUtils.WantedCrewKinds.Contains(type)
- && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase))
+ if (string.IsNullOrWhiteSpace(crewMember.Name))
{
continue;
}
- metadataResult.AddPerson(new PersonInfo
+ var personInfo = new PersonInfo
{
- Name = person.Name.Trim(),
- Role = person.Job?.Trim(),
- Type = type
- });
+ Name = crewMember.Name.Trim(),
+ Role = crewMember.Job?.Trim() ?? string.Empty,
+ Type = entry.PersonType,
+ ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath)
+ };
+
+ if (crewMember.Id > 0)
+ {
+ personInfo.SetProviderId(MetadataProvider.Tmdb, crewMember.Id.ToString(CultureInfo.InvariantCulture));
+ }
+
+ metadataResult.AddPerson(personInfo);
}
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
index a743601ed..5b2f0d26e 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs
@@ -68,7 +68,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
var seasonResult = await _tmdbClientManager
- .GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, null, null, cancellationToken)
+ .GetSeasonAsync(seriesTmdbId, season.IndexNumber.Value, null, null, null, cancellationToken)
.ConfigureAwait(false);
var posters = seasonResult?.Images?.Posters;
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
index b0a1e00df..1b429039e 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs
@@ -42,6 +42,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<Season>();
+ var config = Plugin.Instance.Configuration;
info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId);
@@ -53,7 +54,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
}
var seasonResult = await _tmdbClientManager
- .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken)
+ .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (seasonResult is null)
@@ -65,10 +66,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
result.Item = new Season
{
IndexNumber = seasonNumber,
- Overview = seasonResult.Overview
+ Overview = seasonResult.Overview,
+ PremiereDate = seasonResult.AirDate,
+ ProductionYear = seasonResult.AirDate?.Year
};
- if (Plugin.Instance.Configuration.ImportSeasonName)
+ if (config.ImportSeasonName)
{
result.Item.Name = seasonResult.Name;
}
@@ -77,47 +80,81 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// TODO why was this disabled?
var credits = seasonResult.Credits;
+
if (credits?.Cast is not null)
{
- var cast = credits.Cast.OrderBy(c => c.Order).Take(Plugin.Instance.Configuration.MaxCastMembers).ToList();
- for (var i = 0; i < cast.Count; i++)
+ var castQuery = config.HideMissingCastMembers
+ ? credits.Cast.Where(a => !string.IsNullOrEmpty(a.ProfilePath)).OrderBy(a => a.Order)
+ : credits.Cast.OrderBy(a => a.Order);
+
+ foreach (var actor in castQuery.Take(config.MaxCastMembers))
{
- var member = cast[i];
- result.AddPerson(new PersonInfo
+ if (string.IsNullOrWhiteSpace(actor.Name))
+ {
+ continue;
+ }
+
+ var personInfo = new PersonInfo
{
- Name = member.Name.Trim(),
- Role = member.Character.Trim(),
+ Name = actor.Name.Trim(),
+ Role = actor.Character?.Trim() ?? string.Empty,
Type = PersonKind.Actor,
- SortOrder = member.Order
- });
+ SortOrder = actor.Order,
+ ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath)
+ };
+
+ if (actor.Id > 0)
+ {
+ personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture));
+ }
+
+ result.AddPerson(personInfo);
}
}
if (credits?.Crew is not null)
{
- foreach (var person in credits.Crew)
+ var crewQuery = credits.Crew
+ .Select(crewMember => new
+ {
+ CrewMember = crewMember,
+ PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
+ })
+ .Where(entry =>
+ TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
+ TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
+
+ if (config.HideMissingCrewMembers)
{
- // Normalize this
- var type = TmdbUtils.MapCrewToPersonType(person);
+ crewQuery = crewQuery.Where(entry => !string.IsNullOrEmpty(entry.CrewMember.ProfilePath));
+ }
- if (!TmdbUtils.WantedCrewKinds.Contains(type)
- && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase))
+ foreach (var entry in crewQuery.Take(config.MaxCrewMembers))
+ {
+ var crewMember = entry.CrewMember;
+
+ if (string.IsNullOrWhiteSpace(crewMember.Name))
{
continue;
}
- result.AddPerson(new PersonInfo
+ var personInfo = new PersonInfo
{
- Name = person.Name.Trim(),
- Role = person.Job?.Trim(),
- Type = type
- });
+ Name = crewMember.Name.Trim(),
+ Role = crewMember.Job?.Trim() ?? string.Empty,
+ Type = entry.PersonType,
+ ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath)
+ };
+
+ if (crewMember.Id > 0)
+ {
+ personInfo.SetProviderId(MetadataProvider.Tmdb, crewMember.Id.ToString(CultureInfo.InvariantCulture));
+ }
+
+ result.AddPerson(personInfo);
}
}
- result.Item.PremiereDate = seasonResult.AirDate;
- result.Item.ProductionYear = seasonResult.AirDate?.Year;
-
return result;
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
index 2cb4fe1c1..5cba84dcb 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs
@@ -68,7 +68,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here
var series = await _tmdbClientManager
- .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, cancellationToken)
+ .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, null, cancellationToken)
.ConfigureAwait(false);
if (series?.Images is null)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index 9ace9c674..f0828e826 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -57,7 +57,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId))
{
var series = await _tmdbClientManager
- .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, cancellationToken)
+ .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (series is not null)
@@ -71,7 +71,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out var imdbId))
{
var findResult = await _tmdbClientManager
- .FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, searchInfo.MetadataLanguage, cancellationToken)
+ .FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
var tvResults = findResult?.TvResults;
@@ -92,7 +92,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
if (searchInfo.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId))
{
var findResult = await _tmdbClientManager
- .FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, searchInfo.MetadataLanguage, cancellationToken)
+ .FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
var tvResults = findResult?.TvResults;
@@ -110,7 +110,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
}
}
- var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, cancellationToken: cancellationToken)
+ var tvSearchResults = await _tmdbClientManager.SearchSeriesAsync(searchInfo.Name, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken: cancellationToken)
.ConfigureAwait(false);
var remoteResults = new RemoteSearchResult[tvSearchResults.Count];
@@ -173,7 +173,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Imdb, out var imdbId))
{
- var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
+ var searchResult = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (searchResult?.TvResults.Count > 0)
{
tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture);
@@ -182,7 +182,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
if (string.IsNullOrEmpty(tmdbId) && info.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId))
{
- var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, cancellationToken).ConfigureAwait(false);
+ var searchResult = await _tmdbClientManager.FindByExternalIdAsync(tvdbId, FindExternalSource.TvDb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (searchResult?.TvResults.Count > 0)
{
tmdbId = searchResult.TvResults[0].Id.ToString(CultureInfo.InvariantCulture);
@@ -196,7 +196,7 @@ 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.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false);
+ var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false);
if (searchResults.Count > 0)
{
@@ -212,7 +212,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
cancellationToken.ThrowIfCancellationRequested();
var tvShow = await _tmdbClientManager
- .GetSeriesAsync(tmdbIdInt, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken)
+ .GetSeriesAsync(tmdbIdInt, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken)
.ConfigureAwait(false);
if (tvShow is null)
@@ -323,17 +323,31 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
private IEnumerable<PersonInfo> GetPersons(TvShow seriesResult)
{
+ var config = Plugin.Instance.Configuration;
+
if (seriesResult.Credits?.Cast is not null)
{
- foreach (var actor in seriesResult.Credits.Cast.OrderBy(a => a.Order).Take(Plugin.Instance.Configuration.MaxCastMembers))
+ IEnumerable<Cast> castQuery = seriesResult.Credits.Cast.OrderBy(a => a.Order);
+
+ if (config.HideMissingCastMembers)
+ {
+ castQuery = castQuery.Where(a => !string.IsNullOrEmpty(a.ProfilePath));
+ }
+
+ foreach (var actor in castQuery.Take(config.MaxCastMembers))
{
+ if (string.IsNullOrWhiteSpace(actor.Name))
+ {
+ continue;
+ }
+
var personInfo = new PersonInfo
{
Name = actor.Name.Trim(),
- Role = actor.Character.Trim(),
+ Role = actor.Character?.Trim() ?? string.Empty,
Type = PersonKind.Actor,
SortOrder = actor.Order,
- ImageUrl = _tmdbClientManager.GetPosterUrl(actor.ProfilePath)
+ ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath)
};
if (actor.Id > 0)
@@ -347,30 +361,44 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
if (seriesResult.Credits?.Crew is not null)
{
- var keepTypes = new[]
+ var crewQuery = seriesResult.Credits.Crew
+ .Select(crewMember => new
+ {
+ CrewMember = crewMember,
+ PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
+ })
+ .Where(entry =>
+ TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
+ TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
+
+ if (config.HideMissingCrewMembers)
{
- PersonType.Director,
- PersonType.Writer,
- PersonType.Producer
- };
+ crewQuery = crewQuery.Where(entry => !string.IsNullOrEmpty(entry.CrewMember.ProfilePath));
+ }
- foreach (var person in seriesResult.Credits.Crew)
+ foreach (var entry in crewQuery.Take(config.MaxCrewMembers))
{
- // Normalize this
- var type = TmdbUtils.MapCrewToPersonType(person);
+ var crewMember = entry.CrewMember;
- if (!TmdbUtils.WantedCrewKinds.Contains(type)
- && !TmdbUtils.WantedCrewTypes.Contains(person.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase))
+ if (string.IsNullOrWhiteSpace(crewMember.Name))
{
continue;
}
- yield return new PersonInfo
+ var personInfo = new PersonInfo
{
- Name = person.Name.Trim(),
- Role = person.Job?.Trim(),
- Type = type
+ Name = crewMember.Name.Trim(),
+ Role = crewMember.Job?.Trim() ?? string.Empty,
+ Type = entry.PersonType,
+ ImageUrl = _tmdbClientManager.GetProfileUrl(crewMember.ProfilePath)
};
+
+ if (crewMember.Id > 0)
+ {
+ personInfo.SetProviderId(MetadataProvider.Tmdb, crewMember.Id.ToString(CultureInfo.InvariantCulture));
+ }
+
+ yield return personInfo;
}
}
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
index 4916a95d9..fedf34598 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs
@@ -51,9 +51,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <param name="tmdbId">The movie's TMDb id.</param>
/// <param name="language">The movie's language.</param>
/// <param name="imageLanguages">A comma-separated list of image languages.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb movie or null if not found.</returns>
- public async Task<Movie?> GetMovieAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken)
+ public async Task<Movie?> GetMovieAsync(int tmdbId, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken)
{
var key = $"movie-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}";
if (_memoryCache.TryGetValue(key, out Movie? movie))
@@ -71,7 +72,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
movie = await _tmDbClient.GetMovieAsync(
tmdbId,
- TmdbUtils.NormalizeLanguage(language),
+ TmdbUtils.NormalizeLanguage(language, countryCode),
imageLanguages,
extraMethods,
cancellationToken).ConfigureAwait(false);
@@ -90,9 +91,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <param name="tmdbId">The collection's TMDb id.</param>
/// <param name="language">The collection's language.</param>
/// <param name="imageLanguages">A comma-separated list of image languages.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb collection or null if not found.</returns>
- public async Task<Collection?> GetCollectionAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken)
+ public async Task<Collection?> GetCollectionAsync(int tmdbId, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken)
{
var key = $"collection-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}";
if (_memoryCache.TryGetValue(key, out Collection? collection))
@@ -104,7 +106,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
collection = await _tmDbClient.GetCollectionAsync(
tmdbId,
- TmdbUtils.NormalizeLanguage(language),
+ TmdbUtils.NormalizeLanguage(language, countryCode),
imageLanguages,
CollectionMethods.Images,
cancellationToken).ConfigureAwait(false);
@@ -123,9 +125,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <param name="tmdbId">The tv show's TMDb id.</param>
/// <param name="language">The tv show's language.</param>
/// <param name="imageLanguages">A comma-separated list of image languages.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb tv show information or null if not found.</returns>
- public async Task<TvShow?> GetSeriesAsync(int tmdbId, string? language, string? imageLanguages, CancellationToken cancellationToken)
+ public async Task<TvShow?> GetSeriesAsync(int tmdbId, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken)
{
var key = $"series-{tmdbId.ToString(CultureInfo.InvariantCulture)}-{language}";
if (_memoryCache.TryGetValue(key, out TvShow? series))
@@ -143,7 +146,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
series = await _tmDbClient.GetTvShowAsync(
tmdbId,
- language: TmdbUtils.NormalizeLanguage(language),
+ language: TmdbUtils.NormalizeLanguage(language, countryCode),
includeImageLanguage: imageLanguages,
extraMethods: extraMethods,
cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -163,9 +166,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <param name="displayOrder">The display order.</param>
/// <param name="language">The tv show's language.</param>
/// <param name="imageLanguages">A comma-separated list of image languages.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb tv show episode group information or null if not found.</returns>
- private async Task<TvGroupCollection?> GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken)
+ private async Task<TvGroupCollection?> GetSeriesGroupAsync(int tvShowId, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken)
{
TvGroupType? groupType =
string.Equals(displayOrder, "originalAirDate", StringComparison.Ordinal) ? TvGroupType.OriginalAirDate :
@@ -190,7 +194,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
await EnsureClientConfigAsync().ConfigureAwait(false);
- var series = await GetSeriesAsync(tvShowId, language, imageLanguages, cancellationToken).ConfigureAwait(false);
+ var series = await GetSeriesAsync(tvShowId, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false);
var episodeGroupId = series?.EpisodeGroups.Results.Find(g => g.Type == groupType)?.Id;
if (episodeGroupId is null)
@@ -200,7 +204,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
group = await _tmDbClient.GetTvEpisodeGroupsAsync(
episodeGroupId,
- language: TmdbUtils.NormalizeLanguage(language),
+ language: TmdbUtils.NormalizeLanguage(language, countryCode),
cancellationToken: cancellationToken).ConfigureAwait(false);
if (group is not null)
@@ -218,9 +222,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <param name="seasonNumber">The season number.</param>
/// <param name="language">The tv season's language.</param>
/// <param name="imageLanguages">A comma-separated list of image languages.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb tv season information or null if not found.</returns>
- public async Task<TvSeason?> GetSeasonAsync(int tvShowId, int seasonNumber, string? language, string? imageLanguages, CancellationToken cancellationToken)
+ public async Task<TvSeason?> GetSeasonAsync(int tvShowId, int seasonNumber, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken)
{
var key = $"season-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}-{language}";
if (_memoryCache.TryGetValue(key, out TvSeason? season))
@@ -233,7 +238,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
season = await _tmDbClient.GetTvSeasonAsync(
tvShowId,
seasonNumber,
- language: TmdbUtils.NormalizeLanguage(language),
+ language: TmdbUtils.NormalizeLanguage(language, countryCode),
includeImageLanguage: imageLanguages,
extraMethods: TvSeasonMethods.Credits | TvSeasonMethods.Images | TvSeasonMethods.ExternalIds | TvSeasonMethods.Videos,
cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -255,9 +260,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <param name="displayOrder">The display order.</param>
/// <param name="language">The episode's language.</param>
/// <param name="imageLanguages">A comma-separated list of image languages.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb tv episode information or null if not found.</returns>
- public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, CancellationToken cancellationToken)
+ public async Task<TvEpisode?> GetEpisodeAsync(int tvShowId, int seasonNumber, int episodeNumber, string displayOrder, string? language, string? imageLanguages, string? countryCode, CancellationToken cancellationToken)
{
var key = $"episode-{tvShowId.ToString(CultureInfo.InvariantCulture)}-s{seasonNumber.ToString(CultureInfo.InvariantCulture)}e{episodeNumber.ToString(CultureInfo.InvariantCulture)}-{displayOrder}-{language}";
if (_memoryCache.TryGetValue(key, out TvEpisode? episode))
@@ -267,7 +273,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
await EnsureClientConfigAsync().ConfigureAwait(false);
- var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, cancellationToken).ConfigureAwait(false);
+ var group = await GetSeriesGroupAsync(tvShowId, displayOrder, language, imageLanguages, countryCode, cancellationToken).ConfigureAwait(false);
if (group is not null)
{
var season = group.Groups.Find(s => s.Order == seasonNumber);
@@ -284,7 +290,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
tvShowId,
seasonNumber,
episodeNumber,
- language: TmdbUtils.NormalizeLanguage(language),
+ language: TmdbUtils.NormalizeLanguage(language, countryCode),
includeImageLanguage: imageLanguages,
extraMethods: TvEpisodeMethods.Credits | TvEpisodeMethods.Images | TvEpisodeMethods.ExternalIds | TvEpisodeMethods.Videos,
cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -301,10 +307,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// Gets a person eg. cast or crew member from the TMDb API based on its TMDb id.
/// </summary>
/// <param name="personTmdbId">The person's TMDb id.</param>
- /// <param name="language">The episode's language.</param>
+ /// <param name="language">The person's language.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb person information or null if not found.</returns>
- public async Task<Person?> GetPersonAsync(int personTmdbId, string language, CancellationToken cancellationToken)
+ public async Task<Person?> GetPersonAsync(int personTmdbId, string language, string? countryCode, CancellationToken cancellationToken)
{
var key = $"person-{personTmdbId.ToString(CultureInfo.InvariantCulture)}-{language}";
if (_memoryCache.TryGetValue(key, out Person? person))
@@ -316,7 +323,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
person = await _tmDbClient.GetPersonAsync(
personTmdbId,
- TmdbUtils.NormalizeLanguage(language),
+ TmdbUtils.NormalizeLanguage(language, countryCode),
PersonMethods.TvCredits | PersonMethods.MovieCredits | PersonMethods.Images | PersonMethods.ExternalIds,
cancellationToken).ConfigureAwait(false);
@@ -334,12 +341,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <param name="externalId">The item's external id.</param>
/// <param name="source">The source of the id eg. IMDb.</param>
/// <param name="language">The item's language.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb item or null if not found.</returns>
public async Task<FindContainer?> FindByExternalIdAsync(
string externalId,
FindExternalSource source,
string language,
+ string? countryCode,
CancellationToken cancellationToken)
{
var key = $"find-{source.ToString()}-{externalId.ToString(CultureInfo.InvariantCulture)}-{language}";
@@ -353,7 +362,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
result = await _tmDbClient.FindAsync(
source,
externalId,
- TmdbUtils.NormalizeLanguage(language),
+ TmdbUtils.NormalizeLanguage(language, countryCode),
cancellationToken).ConfigureAwait(false);
if (result is not null)
@@ -369,12 +378,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// </summary>
/// <param name="name">The name of the tv show.</param>
/// <param name="language">The tv show's language.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="year">The year the tv show first aired.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb tv show information.</returns>
- public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, int year = 0, CancellationToken cancellationToken = default)
+ public async Task<IReadOnlyList<SearchTv>> SearchSeriesAsync(string name, string language, string? countryCode, int year = 0, CancellationToken cancellationToken = default)
{
- var key = $"searchseries-{name}-{language}";
+ var key = $"searchseries-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}";
if (_memoryCache.TryGetValue(key, out SearchContainer<SearchTv>? series) && series is not null)
{
return series.Results;
@@ -383,7 +393,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
- .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken)
+ .SearchTvShowAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, firstAirDateYear: year, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (searchResults.Results.Count > 0)
@@ -431,7 +441,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <returns>The TMDb movie information.</returns>
public Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, string language, CancellationToken cancellationToken)
{
- return SearchMovieAsync(name, 0, language, cancellationToken);
+ return SearchMovieAsync(name, 0, language, null, cancellationToken);
}
/// <summary>
@@ -440,9 +450,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// <param name="name">The name of the movie.</param>
/// <param name="year">The release year of the movie.</param>
/// <param name="language">The movie's language.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb movie information.</returns>
- public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, CancellationToken cancellationToken)
+ public async Task<IReadOnlyList<SearchMovie>> SearchMovieAsync(string name, int year, string language, string? countryCode, CancellationToken cancellationToken)
{
var key = $"moviesearch-{name}-{year.ToString(CultureInfo.InvariantCulture)}-{language}";
if (_memoryCache.TryGetValue(key, out SearchContainer<SearchMovie>? movies) && movies is not null)
@@ -453,7 +464,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
- .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken)
+ .SearchMovieAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), includeAdult: Plugin.Instance.Configuration.IncludeAdult, year: year, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (searchResults.Results.Count > 0)
@@ -469,9 +480,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// </summary>
/// <param name="name">The name of the collection.</param>
/// <param name="language">The collection's language.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The TMDb collection information.</returns>
- public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, CancellationToken cancellationToken)
+ public async Task<IReadOnlyList<SearchCollection>> SearchCollectionAsync(string name, string language, string? countryCode, CancellationToken cancellationToken)
{
var key = $"collectionsearch-{name}-{language}";
if (_memoryCache.TryGetValue(key, out SearchContainer<SearchCollection>? collections) && collections is not null)
@@ -482,7 +494,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
- .SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language), cancellationToken: cancellationToken)
+ .SearchCollectionAsync(name, TmdbUtils.NormalizeLanguage(language, countryCode), cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (searchResults.Results.Count > 0)
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs
index 27e3f93a3..8d9ec10c1 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbExternalUrlProvider.cs
@@ -30,7 +30,7 @@ public class TmdbExternalUrlProvider : IExternalUrlProvider
break;
case Season season:
- if (season.Series.TryGetProviderId(MetadataProvider.Tmdb, out var seriesExternalId))
+ if (season.Series?.TryGetProviderId(MetadataProvider.Tmdb, out var seriesExternalId) == true)
{
var orderString = season.Series.DisplayOrder;
var seasonNumber = season.IndexNumber;
@@ -51,7 +51,7 @@ public class TmdbExternalUrlProvider : IExternalUrlProvider
break;
case Episode episode:
- if (episode.Series.TryGetProviderId(MetadataProvider.Imdb, out seriesExternalId))
+ if (episode.Series?.TryGetProviderId(MetadataProvider.Tmdb, out seriesExternalId) == true)
{
var orderString = episode.Series.DisplayOrder;
var seasonNumber = episode.Season?.IndexNumber;
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
index a7c93ac4c..f5e59a278 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
@@ -48,7 +48,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
PersonKind.Producer
};
- [GeneratedRegex(@"[\W_]+")]
+ [GeneratedRegex(@"[\W_-[·]]+")]
private static partial Regex NonWordRegex();
/// <summary>
@@ -105,14 +105,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// Normalizes a language string for use with TMDb's include image language parameter.
/// </summary>
/// <param name="preferredLanguage">The preferred language as either a 2 letter code with or without country code.</param>
+ /// <param name="countryCode">The country code, ISO 3166-1.</param>
/// <returns>The comma separated language string.</returns>
- public static string GetImageLanguagesParam(string preferredLanguage)
+ public static string GetImageLanguagesParam(string preferredLanguage, string? countryCode = null)
{
var languages = new List<string>();
if (!string.IsNullOrEmpty(preferredLanguage))
{
- preferredLanguage = NormalizeLanguage(preferredLanguage);
+ preferredLanguage = NormalizeLanguage(preferredLanguage, countryCode);
languages.Add(preferredLanguage);
@@ -140,15 +141,24 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// Normalizes a language string for use with TMDb's language parameter.
/// </summary>
/// <param name="language">The language code.</param>
+ /// <param name="countryCode">The country code.</param>
/// <returns>The normalized language code.</returns>
[return: NotNullIfNotNull(nameof(language))]
- public static string? NormalizeLanguage(string? language)
+ public static string? NormalizeLanguage(string? language, string? countryCode = null)
{
if (string.IsNullOrEmpty(language))
{
return language;
}
+ // Handle es-419 (Latin American Spanish) by converting to regional variant
+ if (string.Equals(language, "es-419", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(countryCode))
+ {
+ language = string.Equals(countryCode, "AR", StringComparison.OrdinalIgnoreCase)
+ ? "es-AR"
+ : "es-MX";
+ }
+
// TMDb requires this to be uppercase
// Everything after the hyphen must be written in uppercase due to a way TMDb wrote their API.
// See here: https://www.themoviedb.org/talk/5119221d760ee36c642af4ad?page=3#56e372a0c3a3685a9e0019ab
@@ -185,7 +195,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
return requestLanguage;
}
- return imageLanguage;
+ // TMDb now returns xx for no language instead of an empty string.
+ return string.Equals(imageLanguage, "xx", StringComparison.OrdinalIgnoreCase)
+ ? string.Empty
+ : imageLanguage;
}
/// <summary>
diff --git a/MediaBrowser.Providers/Studios/StudioMetadataService.cs b/MediaBrowser.Providers/Studios/StudioMetadataService.cs
index df938325f..fb8cd36c4 100644
--- a/MediaBrowser.Providers/Studios/StudioMetadataService.cs
+++ b/MediaBrowser.Providers/Studios/StudioMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Studios
+namespace MediaBrowser.Providers.Studios;
+
+/// <summary>
+/// Service to manage studio metadata.
+/// </summary>
+public class StudioMetadataService : MetadataService<Studio, ItemLookupInfo>
{
- public class StudioMetadataService : MetadataService<Studio, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="StudioMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public StudioMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<StudioMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public StudioMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<StudioMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs
index 9b4793ee6..31f068711 100644
--- a/MediaBrowser.Providers/TV/EpisodeMetadataService.cs
+++ b/MediaBrowser.Providers/TV/EpisodeMetadataService.cs
@@ -1,113 +1,113 @@
using System;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.TV
+namespace MediaBrowser.Providers.TV;
+
+/// <summary>
+/// Service to manage episode metadata.
+/// </summary>
+public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo>
{
/// <summary>
- /// Service to manage episode metadata.
+ /// Initializes a new instance of the <see cref="EpisodeMetadataService"/> class.
/// </summary>
- public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public EpisodeMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<EpisodeMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
+ {
+ }
+
+ /// <inheritdoc />
+ protected override ItemUpdateType BeforeSaveInternal(Episode item, bool isFullRefresh, ItemUpdateType updateType)
{
- /// <summary>
- /// Initializes a new instance of the <see cref="EpisodeMetadataService"/> class.
- /// </summary>
- /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
- /// <param name="logger">Instance of the <see cref="ILogger{SeasonMetadataService}"/> interface.</param>
- /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- public EpisodeMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<EpisodeMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
+ var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
+
+ var seriesName = item.FindSeriesName();
+ if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal))
{
+ item.SeriesName = seriesName;
+ updatedType |= ItemUpdateType.MetadataImport;
}
- /// <inheritdoc />
- protected override ItemUpdateType BeforeSaveInternal(Episode item, bool isFullRefresh, ItemUpdateType updateType)
+ var seasonName = item.FindSeasonName();
+ if (!string.Equals(item.SeasonName, seasonName, StringComparison.Ordinal))
{
- var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
-
- var seriesName = item.FindSeriesName();
- if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal))
- {
- item.SeriesName = seriesName;
- updatedType |= ItemUpdateType.MetadataImport;
- }
-
- var seasonName = item.FindSeasonName();
- if (!string.Equals(item.SeasonName, seasonName, StringComparison.Ordinal))
- {
- item.SeasonName = seasonName;
- updatedType |= ItemUpdateType.MetadataImport;
- }
-
- var seriesId = item.FindSeriesId();
- if (!item.SeriesId.Equals(seriesId))
- {
- item.SeriesId = seriesId;
- updatedType |= ItemUpdateType.MetadataImport;
- }
-
- var seasonId = item.FindSeasonId();
- if (!item.SeasonId.Equals(seasonId))
- {
- item.SeasonId = seasonId;
- updatedType |= ItemUpdateType.MetadataImport;
- }
+ item.SeasonName = seasonName;
+ updatedType |= ItemUpdateType.MetadataImport;
+ }
- var seriesPresentationUniqueKey = item.FindSeriesPresentationUniqueKey();
- if (!string.Equals(item.SeriesPresentationUniqueKey, seriesPresentationUniqueKey, StringComparison.Ordinal))
- {
- item.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
- updatedType |= ItemUpdateType.MetadataImport;
- }
+ var seriesId = item.FindSeriesId();
+ if (!item.SeriesId.Equals(seriesId))
+ {
+ item.SeriesId = seriesId;
+ updatedType |= ItemUpdateType.MetadataImport;
+ }
- return updatedType;
+ var seasonId = item.FindSeasonId();
+ if (!item.SeasonId.Equals(seasonId))
+ {
+ item.SeasonId = seasonId;
+ updatedType |= ItemUpdateType.MetadataImport;
}
- /// <inheritdoc />
- protected override void MergeData(MetadataResult<Episode> source, MetadataResult<Episode> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ var seriesPresentationUniqueKey = item.FindSeriesPresentationUniqueKey();
+ if (!string.Equals(item.SeriesPresentationUniqueKey, seriesPresentationUniqueKey, StringComparison.Ordinal))
{
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ item.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
+ updatedType |= ItemUpdateType.MetadataImport;
+ }
+
+ return updatedType;
+ }
- var sourceItem = source.Item;
- var targetItem = target.Item;
+ /// <inheritdoc />
+ protected override void MergeData(MetadataResult<Episode> source, MetadataResult<Episode> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
- if (replaceData || !targetItem.AirsBeforeSeasonNumber.HasValue)
- {
- targetItem.AirsBeforeSeasonNumber = sourceItem.AirsBeforeSeasonNumber;
- }
+ var sourceItem = source.Item;
+ var targetItem = target.Item;
- if (replaceData || !targetItem.AirsAfterSeasonNumber.HasValue)
- {
- targetItem.AirsAfterSeasonNumber = sourceItem.AirsAfterSeasonNumber;
- }
+ if (replaceData || !targetItem.AirsBeforeSeasonNumber.HasValue)
+ {
+ targetItem.AirsBeforeSeasonNumber = sourceItem.AirsBeforeSeasonNumber;
+ }
- if (replaceData || !targetItem.AirsBeforeEpisodeNumber.HasValue)
- {
- targetItem.AirsBeforeEpisodeNumber = sourceItem.AirsBeforeEpisodeNumber;
- }
+ if (replaceData || !targetItem.AirsAfterSeasonNumber.HasValue)
+ {
+ targetItem.AirsAfterSeasonNumber = sourceItem.AirsAfterSeasonNumber;
+ }
- if (replaceData || !targetItem.IndexNumberEnd.HasValue)
- {
- targetItem.IndexNumberEnd = sourceItem.IndexNumberEnd;
- }
+ if (replaceData || !targetItem.AirsBeforeEpisodeNumber.HasValue)
+ {
+ targetItem.AirsBeforeEpisodeNumber = sourceItem.AirsBeforeEpisodeNumber;
+ }
- if (replaceData || !targetItem.ParentIndexNumber.HasValue)
- {
- targetItem.ParentIndexNumber = sourceItem.ParentIndexNumber;
- }
+ if (replaceData || !targetItem.IndexNumberEnd.HasValue)
+ {
+ targetItem.IndexNumberEnd = sourceItem.IndexNumberEnd;
}
}
}
diff --git a/MediaBrowser.Providers/TV/SeasonMetadataService.cs b/MediaBrowser.Providers/TV/SeasonMetadataService.cs
index b27ccaa6a..886175dea 100644
--- a/MediaBrowser.Providers/TV/SeasonMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeasonMetadataService.cs
@@ -4,109 +4,114 @@ using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.TV
+namespace MediaBrowser.Providers.TV;
+
+/// <summary>
+/// Service to manage season metadata.
+/// </summary>
+public class SeasonMetadataService : MetadataService<Season, SeasonInfo>
{
/// <summary>
- /// Service to manage season metadata.
+ /// Initializes a new instance of the <see cref="SeasonMetadataService"/> class.
/// </summary>
- public class SeasonMetadataService : MetadataService<Season, SeasonInfo>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public SeasonMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<SeasonMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- /// <summary>
- /// Initializes a new instance of the <see cref="SeasonMetadataService"/> class.
- /// </summary>
- /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
- /// <param name="logger">Instance of the <see cref="ILogger{SeasonMetadataService}"/> interface.</param>
- /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- public SeasonMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<SeasonMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
+ }
- /// <inheritdoc />
- protected override bool EnableUpdatingPremiereDateFromChildren => true;
+ /// <inheritdoc />
+ protected override bool EnableUpdatingPremiereDateFromChildren => true;
- /// <inheritdoc />
- protected override ItemUpdateType BeforeSaveInternal(Season item, bool isFullRefresh, ItemUpdateType updateType)
- {
- var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
-
- if (item.IndexNumber == 0 && !item.IsLocked && !item.LockedFields.Contains(MetadataField.Name))
- {
- var seasonZeroDisplayName = LibraryManager.GetLibraryOptions(item).SeasonZeroDisplayName;
+ /// <inheritdoc />
+ protected override ItemUpdateType BeforeSaveInternal(Season item, bool isFullRefresh, ItemUpdateType updateType)
+ {
+ var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
- if (!string.Equals(item.Name, seasonZeroDisplayName, StringComparison.OrdinalIgnoreCase))
- {
- item.Name = seasonZeroDisplayName;
- updatedType |= ItemUpdateType.MetadataEdit;
- }
- }
+ if (item.IndexNumber == 0 && !item.IsLocked && !item.LockedFields.Contains(MetadataField.Name))
+ {
+ var seasonZeroDisplayName = LibraryManager.GetLibraryOptions(item).SeasonZeroDisplayName;
- var seriesName = item.FindSeriesName();
- if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal))
+ if (!string.Equals(item.Name, seasonZeroDisplayName, StringComparison.OrdinalIgnoreCase))
{
- item.SeriesName = seriesName;
- updatedType |= ItemUpdateType.MetadataImport;
+ item.Name = seasonZeroDisplayName;
+ updatedType |= ItemUpdateType.MetadataEdit;
}
+ }
- var seriesPresentationUniqueKey = item.FindSeriesPresentationUniqueKey();
- if (!string.Equals(item.SeriesPresentationUniqueKey, seriesPresentationUniqueKey, StringComparison.Ordinal))
- {
- item.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
- updatedType |= ItemUpdateType.MetadataImport;
- }
+ var seriesName = item.FindSeriesName();
+ if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal))
+ {
+ item.SeriesName = seriesName;
+ updatedType |= ItemUpdateType.MetadataImport;
+ }
- var seriesId = item.FindSeriesId();
- if (!item.SeriesId.Equals(seriesId))
- {
- item.SeriesId = seriesId;
- updatedType |= ItemUpdateType.MetadataImport;
- }
+ var seriesPresentationUniqueKey = item.FindSeriesPresentationUniqueKey();
+ if (!string.Equals(item.SeriesPresentationUniqueKey, seriesPresentationUniqueKey, StringComparison.Ordinal))
+ {
+ item.SeriesPresentationUniqueKey = seriesPresentationUniqueKey;
+ updatedType |= ItemUpdateType.MetadataImport;
+ }
- return updatedType;
+ var seriesId = item.FindSeriesId();
+ if (!item.SeriesId.Equals(seriesId))
+ {
+ item.SeriesId = seriesId;
+ updatedType |= ItemUpdateType.MetadataImport;
}
- /// <inheritdoc />
- protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(Season item)
- => item.GetEpisodes();
+ return updatedType;
+ }
- /// <inheritdoc />
- protected override ItemUpdateType UpdateMetadataFromChildren(Season item, IReadOnlyList<BaseItem> children, bool isFullRefresh, ItemUpdateType currentUpdateType)
- {
- var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType);
+ /// <inheritdoc />
+ protected override IReadOnlyList<BaseItem> GetChildrenForMetadataUpdates(Season item)
+ => item.GetEpisodes();
- if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
- {
- updateType |= SaveIsVirtualItem(item, children);
- }
+ /// <inheritdoc />
+ protected override ItemUpdateType UpdateMetadataFromChildren(Season item, IReadOnlyList<BaseItem> children, bool isFullRefresh, ItemUpdateType currentUpdateType)
+ {
+ var updateType = base.UpdateMetadataFromChildren(item, children, isFullRefresh, currentUpdateType);
- return updateType;
+ if (isFullRefresh || currentUpdateType > ItemUpdateType.None)
+ {
+ updateType |= SaveIsVirtualItem(item, children);
}
- private ItemUpdateType SaveIsVirtualItem(Season item, IReadOnlyList<BaseItem> episodes)
- {
- var isVirtualItem = item.LocationType == LocationType.Virtual && (episodes.Count == 0 || episodes.All(i => i.LocationType == LocationType.Virtual));
+ return updateType;
+ }
- if (item.IsVirtualItem != isVirtualItem)
- {
- item.IsVirtualItem = isVirtualItem;
- return ItemUpdateType.MetadataEdit;
- }
+ private ItemUpdateType SaveIsVirtualItem(Season item, IReadOnlyList<BaseItem> episodes)
+ {
+ var isVirtualItem = item.LocationType == LocationType.Virtual && (episodes.Count == 0 || episodes.All(i => i.LocationType == LocationType.Virtual));
- return ItemUpdateType.None;
+ if (item.IsVirtualItem != isVirtualItem)
+ {
+ item.IsVirtualItem = isVirtualItem;
+ return ItemUpdateType.MetadataEdit;
}
+
+ return ItemUpdateType.None;
}
}
diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
index 42d59d348..c3a6ddd6a 100644
--- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
@@ -8,7 +8,9 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -16,269 +18,272 @@ using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.TV
+namespace MediaBrowser.Providers.TV;
+
+/// <summary>
+/// Service to manage series metadata.
+/// </summary>
+public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
{
+ private readonly ILocalizationManager _localizationManager;
+
/// <summary>
- /// Service to manage series metadata.
+ /// Initializes a new instance of the <see cref="SeriesMetadataService"/> class.
/// </summary>
- public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public SeriesMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<SeriesMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ ILocalizationManager localizationManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- private readonly ILocalizationManager _localizationManager;
+ _localizationManager = localizationManager;
+ }
- /// <summary>
- /// Initializes a new instance of the <see cref="SeriesMetadataService"/> class.
- /// </summary>
- /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
- /// <param name="logger">Instance of the <see cref="ILogger{SeasonMetadataService}"/> interface.</param>
- /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
- /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
- /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
- public SeriesMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<SeriesMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager,
- ILocalizationManager localizationManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
+ /// <inheritdoc />
+ public override async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
+ {
+ if (item is Series series)
{
- _localizationManager = localizationManager;
- }
+ var seasons = series.GetRecursiveChildren(i => i is Season).ToList();
- /// <inheritdoc />
- public override async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
- {
- if (item is Series series)
+ foreach (var season in seasons)
{
- var seasons = series.GetRecursiveChildren(i => i is Season).ToList();
-
- foreach (var season in seasons)
+ var hasUpdate = refreshOptions is not null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMetadata);
+ if (hasUpdate)
{
- var hasUpdate = refreshOptions is not null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMetadata);
- if (hasUpdate)
- {
- await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
- }
+ await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
-
- return await base.RefreshMetadata(item, refreshOptions, cancellationToken).ConfigureAwait(false);
}
- /// <inheritdoc />
- protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
- {
- await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
+ return await base.RefreshMetadata(item, refreshOptions, cancellationToken).ConfigureAwait(false);
+ }
- RemoveObsoleteEpisodes(item);
- RemoveObsoleteSeasons(item);
- await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
+ /// <inheritdoc />
+ protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
+ {
+ await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
+
+ RemoveObsoleteEpisodes(item);
+ RemoveObsoleteSeasons(item);
+ await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// <inheritdoc />
+ protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ {
+ base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+
+ var sourceItem = source.Item;
+ var targetItem = target.Item;
+
+ if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
+ {
+ targetItem.AirTime = sourceItem.AirTime;
}
- /// <inheritdoc />
- protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
+ if (replaceData || !targetItem.Status.HasValue)
{
- base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
+ targetItem.Status = sourceItem.Status;
+ }
- var sourceItem = source.Item;
- var targetItem = target.Item;
+ if (replaceData || targetItem.AirDays is null || targetItem.AirDays.Length == 0)
+ {
+ targetItem.AirDays = sourceItem.AirDays;
+ }
+ }
- if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
+ private void RemoveObsoleteSeasons(Series series)
+ {
+ // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in CreateSeasonsAsync.
+ var physicalSeasonNumbers = new HashSet<int>();
+ var virtualSeasons = new List<Season>();
+ foreach (var existingSeason in series.Children.OfType<Season>())
+ {
+ if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue)
{
- targetItem.AirTime = sourceItem.AirTime;
+ physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value);
}
-
- if (replaceData || !targetItem.Status.HasValue)
+ else if (existingSeason.LocationType == LocationType.Virtual)
{
- targetItem.Status = sourceItem.Status;
+ virtualSeasons.Add(existingSeason);
}
+ }
- if (replaceData || targetItem.AirDays is null || targetItem.AirDays.Length == 0)
+ foreach (var virtualSeason in virtualSeasons)
+ {
+ var seasonNumber = virtualSeason.IndexNumber;
+ // If there's a physical season with the same number or no episodes in the season, delete it
+ if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value))
+ || virtualSeason.GetEpisodes().Count == 0)
{
- targetItem.AirDays = sourceItem.AirDays;
+ Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.IndexNumber, series.Name);
+
+ LibraryManager.DeleteItem(
+ virtualSeason,
+ new DeleteOptions
+ {
+ // Internal metadata paths are removed regardless of this.
+ DeleteFileLocation = false
+ },
+ false);
}
}
+ }
- private void RemoveObsoleteSeasons(Series series)
+ private void RemoveObsoleteEpisodes(Series series)
+ {
+ var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true)
+ .OfType<Episode>()
+ .GroupBy(e => e.ParentIndexNumber)
+ .ToList();
+
+ foreach (var seasonEpisodes in episodesBySeason)
{
- // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in CreateSeasonsAsync.
- var physicalSeasonNumbers = new HashSet<int>();
- var virtualSeasons = new List<Season>();
- foreach (var existingSeason in series.Children.OfType<Season>())
+ List<Episode> nonPhysicalEpisodes = [];
+ List<Episode> physicalEpisodes = [];
+ foreach (var episode in seasonEpisodes)
{
- if (existingSeason.LocationType != LocationType.Virtual && existingSeason.IndexNumber.HasValue)
+ if (episode.IsVirtualItem || episode.IsMissingEpisode)
{
- physicalSeasonNumbers.Add(existingSeason.IndexNumber.Value);
- }
- else if (existingSeason.LocationType == LocationType.Virtual)
- {
- virtualSeasons.Add(existingSeason);
+ nonPhysicalEpisodes.Add(episode);
+ continue;
}
+
+ physicalEpisodes.Add(episode);
}
- foreach (var virtualSeason in virtualSeasons)
+ // Only consider non-physical episodes
+ foreach (var episode in nonPhysicalEpisodes)
{
- var seasonNumber = virtualSeason.IndexNumber;
- // If there's a physical season with the same number or no episodes in the season, delete it
- if ((seasonNumber.HasValue && physicalSeasonNumbers.Contains(seasonNumber.Value))
- || virtualSeason.GetEpisodes().Count == 0)
- {
- Logger.LogInformation("Removing virtual season {SeasonNumber} in series {SeriesName}", virtualSeason.IndexNumber, series.Name);
+ // Episodes without an episode number are practically orphaned and should be deleted
+ // Episodes with a physical equivalent should be deleted (they are no longer missing)
+ var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(episode.IndexNumber.Value));
- LibraryManager.DeleteItem(
- virtualSeason,
- new DeleteOptions
- {
- // Internal metadata paths are removed regardless of this.
- DeleteFileLocation = false
- },
- false);
+ if (shouldKeep)
+ {
+ continue;
}
+
+ DeleteEpisode(episode);
}
}
+ }
- private void RemoveObsoleteEpisodes(Series series)
- {
- var episodesBySeason = series.GetEpisodes(null, new DtoOptions(), true)
- .OfType<Episode>()
- .GroupBy(e => e.ParentIndexNumber)
- .ToList();
+ private void DeleteEpisode(Episode episode)
+ {
+ Logger.LogInformation(
+ "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}",
+ episode.ParentIndexNumber,
+ episode.IndexNumber,
+ episode.SeriesName);
- foreach (var seasonEpisodes in episodesBySeason)
+ LibraryManager.DeleteItem(
+ episode,
+ new DeleteOptions
{
- List<Episode> nonPhysicalEpisodes = [];
- List<Episode> physicalEpisodes = [];
- foreach (var episode in seasonEpisodes)
- {
- if (episode.IsVirtualItem || episode.IsMissingEpisode)
- {
- nonPhysicalEpisodes.Add(episode);
- continue;
- }
+ // Internal metadata paths are removed regardless of this.
+ DeleteFileLocation = false
+ },
+ false);
+ }
- physicalEpisodes.Add(episode);
- }
+ /// <summary>
+ /// Creates seasons for all episodes if they don't exist.
+ /// If no season number can be determined, a dummy season will be created.
+ /// </summary>
+ /// <param name="series">The series.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The async task.</returns>
+ private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
+ {
+ var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
+ var seasons = seriesChildren.OfType<Season>().ToList();
+ var uniqueSeasonNumbers = seriesChildren
+ .OfType<Episode>()
+ .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
+ .Distinct();
- // Only consider non-physical episodes
- foreach (var episode in nonPhysicalEpisodes)
+ // Loop through the unique season numbers
+ foreach (var seasonNumber in uniqueSeasonNumbers)
+ {
+ // Null season numbers will have a 'dummy' season created because seasons are always required.
+ var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
+ if (existingSeason is null)
+ {
+ var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
+ await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
+ }
+ else if (existingSeason.IsVirtualItem)
+ {
+ var episodeCount = seriesChildren.OfType<Episode>().Count(e => e.ParentIndexNumber == seasonNumber && !e.IsMissingEpisode);
+ if (episodeCount > 0)
{
- // Episodes without an episode number are practically orphaned and should be deleted
- // Episodes with a physical equivalent should be deleted (they are no longer missing)
- var shouldKeep = episode.IndexNumber.HasValue && !physicalEpisodes.Any(e => e.ContainsEpisodeNumber(episode.IndexNumber.Value));
-
- if (shouldKeep)
- {
- continue;
- }
-
- DeleteEpisode(episode);
+ existingSeason.IsVirtualItem = false;
+ await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
}
+ }
- private void DeleteEpisode(Episode episode)
- {
- Logger.LogInformation(
- "Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}",
- episode.ParentIndexNumber,
- episode.IndexNumber,
- episode.SeriesName);
-
- LibraryManager.DeleteItem(
- episode,
- new DeleteOptions
- {
- // Internal metadata paths are removed regardless of this.
- DeleteFileLocation = false
- },
- false);
- }
+ /// <summary>
+ /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
+ /// </summary>
+ /// <param name="series">The series.</param>
+ /// <param name="seasonName">The season name.</param>
+ /// <param name="seasonNumber">The season number.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The newly created season.</returns>
+ private async Task CreateSeasonAsync(
+ Series series,
+ string? seasonName,
+ int? seasonNumber,
+ CancellationToken cancellationToken)
+ {
+ Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
- /// <summary>
- /// Creates seasons for all episodes if they don't exist.
- /// If no season number can be determined, a dummy season will be created.
- /// </summary>
- /// <param name="series">The series.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>The async task.</returns>
- private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
+ var season = new Season
{
- var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
- var seasons = seriesChildren.OfType<Season>().ToList();
- var uniqueSeasonNumbers = seriesChildren
- .OfType<Episode>()
- .Select(e => e.ParentIndexNumber >= 0 ? e.ParentIndexNumber : null)
- .Distinct();
+ Name = seasonName,
+ IndexNumber = seasonNumber,
+ Id = LibraryManager.GetNewItemId(
+ series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName,
+ typeof(Season)),
+ IsVirtualItem = false,
+ SeriesId = series.Id,
+ SeriesName = series.Name,
+ SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
+ };
- // Loop through the unique season numbers
- foreach (var seasonNumber in uniqueSeasonNumbers)
- {
- // Null season numbers will have a 'dummy' season created because seasons are always required.
- var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
- if (existingSeason is null)
- {
- var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
- await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
- }
- else if (existingSeason.IsVirtualItem)
- {
- var episodeCount = seriesChildren.OfType<Episode>().Count(e => e.ParentIndexNumber == seasonNumber && !e.IsMissingEpisode);
- if (episodeCount > 0)
- {
- existingSeason.IsVirtualItem = false;
- await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
- }
- }
- }
- }
+ series.AddChild(season);
+ await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false);
+ }
- /// <summary>
- /// Creates a new season, adds it to the database by linking it to the [series] and refreshes the metadata.
- /// </summary>
- /// <param name="series">The series.</param>
- /// <param name="seasonName">The season name.</param>
- /// <param name="seasonNumber">The season number.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>The newly created season.</returns>
- private async Task CreateSeasonAsync(
- Series series,
- string? seasonName,
- int? seasonNumber,
- CancellationToken cancellationToken)
+ private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
+ {
+ if (string.IsNullOrEmpty(seasonName))
{
- Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name);
-
- var season = new Season
+ seasonName = seasonNumber switch
{
- Name = seasonName,
- IndexNumber = seasonNumber,
- Id = LibraryManager.GetNewItemId(
- series.Id + (seasonNumber ?? -1).ToString(CultureInfo.InvariantCulture) + seasonName,
- typeof(Season)),
- IsVirtualItem = false,
- SeriesId = series.Id,
- SeriesName = series.Name,
- SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
+ null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
+ 0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
+ _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value)
};
-
- series.AddChild(season);
- await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false);
}
- private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
- {
- if (string.IsNullOrEmpty(seasonName))
- {
- seasonName = seasonNumber switch
- {
- null => _localizationManager.GetLocalizedString("NameSeasonUnknown"),
- 0 => LibraryManager.GetLibraryOptions(series).SeasonZeroDisplayName,
- _ => string.Format(CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("NameSeasonNumber"), seasonNumber.Value)
- };
- }
-
- return seasonName;
- }
+ return seasonName;
}
}
diff --git a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs
index 4310f93d4..81dcbf893 100644
--- a/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs
+++ b/MediaBrowser.Providers/Trickplay/TrickplayImagesTask.cs
@@ -59,14 +59,14 @@ public class TrickplayImagesTask : IScheduledTask
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
- return new[]
- {
+ return
+ [
new TaskTriggerInfo
{
Type = TaskTriggerInfoType.DailyTrigger,
TimeOfDayTicks = TimeSpan.FromHours(3).Ticks
}
- };
+ ];
}
/// <inheritdoc />
@@ -74,8 +74,8 @@ public class TrickplayImagesTask : IScheduledTask
{
var query = new InternalItemsQuery
{
- MediaTypes = new[] { MediaType.Video },
- SourceTypes = new[] { SourceType.Library },
+ MediaTypes = [MediaType.Video],
+ SourceTypes = [SourceType.Library],
IsVirtualItem = false,
IsFolder = false,
Recursive = true,
diff --git a/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs
index 2c74e5f70..926a962e2 100644
--- a/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs
+++ b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs
@@ -56,7 +56,7 @@ public class TrickplayProvider : ICustomMetadataProvider<Episode>,
if (item.IsFileProtocol)
{
var file = directoryService.GetFile(item.Path);
- if (file is not null && item.DateModified != file.LastWriteTimeUtc)
+ if (file is not null && item.HasChanged(file.LastWriteTimeUtc))
{
return true;
}
@@ -101,7 +101,7 @@ public class TrickplayProvider : ICustomMetadataProvider<Episode>,
bool? enableDuringScan = libraryOptions?.ExtractTrickplayImagesDuringLibraryScan;
bool replace = options.RegenerateTrickplay && options.MetadataRefreshMode > MetadataRefreshMode.Default;
- if (!enableDuringScan.GetValueOrDefault(false))
+ if (libraryOptions is null || !enableDuringScan.GetValueOrDefault(false))
{
return ItemUpdateType.None;
}
diff --git a/MediaBrowser.Providers/Videos/VideoMetadataService.cs b/MediaBrowser.Providers/Videos/VideoMetadataService.cs
index caa6d6e1f..464b337ff 100644
--- a/MediaBrowser.Providers/Videos/VideoMetadataService.cs
+++ b/MediaBrowser.Providers/Videos/VideoMetadataService.cs
@@ -1,29 +1,43 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Videos
+namespace MediaBrowser.Providers.Videos;
+
+/// <summary>
+/// Service to manage video metadata.
+/// </summary>
+public class VideoMetadataService : MetadataService<Video, ItemLookupInfo>
{
- public class VideoMetadataService : MetadataService<Video, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="VideoMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public VideoMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<VideoMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public VideoMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<VideoMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
-
- /// <inheritdoc />
- // Make sure the type-specific services get picked first
- public override int Order => 10;
}
+
+ /// <inheritdoc />
+ // Make sure the type-specific services get picked first
+ public override int Order => 10;
}
diff --git a/MediaBrowser.Providers/Years/YearMetadataService.cs b/MediaBrowser.Providers/Years/YearMetadataService.cs
index 689e8661b..cc403e7c9 100644
--- a/MediaBrowser.Providers/Years/YearMetadataService.cs
+++ b/MediaBrowser.Providers/Years/YearMetadataService.cs
@@ -1,25 +1,39 @@
-#pragma warning disable CS1591
-
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Manager;
using Microsoft.Extensions.Logging;
-namespace MediaBrowser.Providers.Years
+namespace MediaBrowser.Providers.Years;
+
+/// <summary>
+/// Service to manage year metadata.
+/// </summary>
+public class YearMetadataService : MetadataService<Year, ItemLookupInfo>
{
- public class YearMetadataService : MetadataService<Year, ItemLookupInfo>
+ /// <summary>
+ /// Initializes a new instance of the <see cref="YearMetadataService"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/>.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="externalDataManager">Instance of the <see cref="IExternalDataManager"/> interface.</param>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ public YearMetadataService(
+ IServerConfigurationManager serverConfigurationManager,
+ ILogger<YearMetadataService> logger,
+ IProviderManager providerManager,
+ IFileSystem fileSystem,
+ ILibraryManager libraryManager,
+ IExternalDataManager externalDataManager,
+ IItemRepository itemRepository)
+ : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager, externalDataManager, itemRepository)
{
- public YearMetadataService(
- IServerConfigurationManager serverConfigurationManager,
- ILogger<YearMetadataService> logger,
- IProviderManager providerManager,
- IFileSystem fileSystem,
- ILibraryManager libraryManager)
- : base(serverConfigurationManager, logger, providerManager, fileSystem, libraryManager)
- {
- }
}
}
diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
index 75ad0d58c..3f83f1d82 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
@@ -107,6 +107,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
// Additional Mappings
_validProviderIds.Add("collectionnumber", "TmdbCollection");
_validProviderIds.Add("tmdbcolid", "TmdbCollection");
+ _validProviderIds.Add("tmdbcol", "TmdbCollection");
_validProviderIds.Add("imdb_id", "Imdb");
Fetch(item, metadataFile, GetXmlReaderSettings(), cancellationToken);
@@ -315,7 +316,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (userData is not null)
{
userData.Played = played;
- _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
+
+ if (!item.Id.IsEmpty())
+ {
+ _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
+ }
}
}
}
@@ -332,7 +337,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (userData is not null)
{
userData.PlayCount = count;
- _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
+
+ if (!item.Id.IsEmpty())
+ {
+ _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
+ }
}
}
}
@@ -349,7 +358,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (userData is not null)
{
userData.LastPlayedDate = lastPlayed;
- _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
+
+ if (!item.Id.IsEmpty())
+ {
+ _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None);
+ }
}
}
}
@@ -590,7 +603,18 @@ namespace MediaBrowser.XbmcMetadata.Parsers
var provider = reader.GetAttribute("type");
var providerId = reader.ReadElementContentAsString();
- item.TrySetProviderId(provider, providerId);
+
+ if (!string.IsNullOrEmpty(provider))
+ {
+ if (_validProviderIds.TryGetValue(provider, out string? normalizedProvider))
+ {
+ item.TrySetProviderId(normalizedProvider, providerId);
+ }
+ else
+ {
+ item.TrySetProviderId(provider, providerId);
+ }
+ }
break;
case "thumb":
diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
index 22c065b5d..5ac672f10 100644
--- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
+++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
@@ -1,5 +1,6 @@
#pragma warning disable CS1591
+using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -67,12 +68,15 @@ namespace MediaBrowser.XbmcMetadata.Providers
{
var file = GetXmlFile(new ItemInfo(item), directoryService);
- if (file is null)
+ if (file?.Exists is not true)
{
return false;
}
- return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
+ var fileTime = _fileSystem.GetLastWriteTimeUtc(file);
+
+ // 1 minute tolerance to avoid detecting our own file writes
+ return (fileTime - item.DateLastSaved) > TimeSpan.FromMinutes(1);
}
protected abstract void Fetch(MetadataResult<T> result, string path, CancellationToken cancellationToken);
diff --git a/README.md b/README.md
index a07fd32ce..9830e8e9c 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,9 @@
---
-Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. It is an alternative to the proprietary Emby and Plex, to provide media from a dedicated server to end-user devices via multiple apps. Jellyfin is descended from Emby's 3.5.2 release and ported to the .NET Core framework to enable full cross-platform support. There are no strings attached, no premium licenses or features, and no hidden agendas: just a team who want to build something better and work together to achieve it. We welcome anyone who is interested in joining us in our quest!
+Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. It is an alternative to the proprietary Emby and Plex, to provide media from a dedicated server to end-user devices via multiple apps. Jellyfin is descended from Emby's 3.5.2 release and ported to the .NET platform to enable full cross-platform support.
+
+There are no strings attached, no premium licenses or features, and no hidden agendas: just a team that wants to build something better and work together to achieve it. We welcome anyone who is interested in joining us in our quest!
For further details, please see [our documentation page](https://jellyfin.org/docs/). To receive the latest updates, get help with Jellyfin, and join the community, please visit [one of our communication channels](https://jellyfin.org/docs/general/getting-help). For more information about the project, please see our [about page](https://jellyfin.org/docs/general/about).
diff --git a/SharedVersion.cs b/SharedVersion.cs
index d26eb31ae..3b394d28b 100644
--- a/SharedVersion.cs
+++ b/SharedVersion.cs
@@ -1,4 +1,4 @@
using System.Reflection;
-[assembly: AssemblyVersion("10.11.0")]
-[assembly: AssemblyFileVersion("10.11.0")]
+[assembly: AssemblyVersion("10.12.0")]
+[assembly: AssemblyFileVersion("10.12.0")]
diff --git a/bump_version b/bump_version
index 6d08dc72f..0516a1806 100755
--- a/bump_version
+++ b/bump_version
@@ -58,7 +58,7 @@ for subproject in ${jellyfin_subprojects[@]}; do
done
# Set the version in the GitHub issue template file
-sed -i "s|${old_version}|${new_version_sed}|g" ${issue_template_file}
+sed -i "s|${old_version}|${new_version_sed}|g" "${issue_template_file}"
# Stage the changed files for commit
git add .
diff --git a/src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md b/src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md
new file mode 100644
index 000000000..d23e3f9ed
--- /dev/null
+++ b/src/Jellyfin.CodeAnalysis/AnalyzerReleases.Shipped.md
@@ -0,0 +1,9 @@
+; Shipped analyzer releases
+; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+## Release 1.0
+
+### New Rules
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+JF0001 | Usage | Warning | Async-created IAsyncDisposable objects should use 'await using'
diff --git a/src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs b/src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs
new file mode 100644
index 000000000..90c8dfeca
--- /dev/null
+++ b/src/Jellyfin.CodeAnalysis/AsyncDisposalPatternAnalyzer.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Jellyfin.CodeAnalysis;
+
+/// <summary>
+/// Analyzer to detect sync disposal of async-created IAsyncDisposable objects.
+/// </summary>
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class AsyncDisposalPatternAnalyzer : DiagnosticAnalyzer
+{
+ /// <summary>
+ /// Diagnostic descriptor for sync disposal of async-created IAsyncDisposable objects.
+ /// </summary>
+ public static readonly DiagnosticDescriptor AsyncDisposableSyncDisposal = new(
+ id: "JF0001",
+ title: "Async-created IAsyncDisposable objects should use 'await using'",
+ messageFormat: "Using 'using' with async-created IAsyncDisposable object '{0}'. Use 'await using' instead to prevent resource leaks.",
+ category: "Usage",
+ defaultSeverity: DiagnosticSeverity.Error,
+ isEnabledByDefault: true,
+ description: "Objects that implement IAsyncDisposable and are created using 'await' should be disposed using 'await using' to prevent resource leaks.");
+
+ /// <inheritdoc/>
+ public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [AsyncDisposableSyncDisposal];
+
+ /// <inheritdoc/>
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+ context.RegisterSyntaxNodeAction(AnalyzeUsingStatement, SyntaxKind.UsingStatement);
+ }
+
+ private static void AnalyzeUsingStatement(SyntaxNodeAnalysisContext context)
+ {
+ var usingStatement = (UsingStatementSyntax)context.Node;
+
+ // Skip 'await using' statements
+ if (usingStatement.AwaitKeyword.IsKind(SyntaxKind.AwaitKeyword))
+ {
+ return;
+ }
+
+ // Check if there's a variable declaration
+ if (usingStatement.Declaration?.Variables is null)
+ {
+ return;
+ }
+
+ foreach (var variable in usingStatement.Declaration.Variables)
+ {
+ if (variable.Initializer?.Value is AwaitExpressionSyntax awaitExpression)
+ {
+ var typeInfo = context.SemanticModel.GetTypeInfo(awaitExpression);
+ var type = typeInfo.Type;
+
+ if (type is not null && ImplementsIAsyncDisposable(type))
+ {
+ var diagnostic = Diagnostic.Create(
+ AsyncDisposableSyncDisposal,
+ usingStatement.GetLocation(),
+ type.Name);
+
+ context.ReportDiagnostic(diagnostic);
+ }
+ }
+ }
+ }
+
+ private static bool ImplementsIAsyncDisposable(ITypeSymbol type)
+ {
+ return type.AllInterfaces.Any(i =>
+ string.Equals(i.Name, "IAsyncDisposable", StringComparison.Ordinal)
+ && string.Equals(i.ContainingNamespace?.ToDisplayString(), "System", StringComparison.Ordinal));
+ }
+}
diff --git a/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj b/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj
new file mode 100644
index 000000000..64d20e904
--- /dev/null
+++ b/src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>netstandard2.0</TargetFramework>
+ <LangVersion>latest</LangVersion>
+ <IncludeBuildOutput>false</IncludeBuildOutput>
+ <GeneratePackageOnBuild>false</GeneratePackageOnBuild>
+ <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
+ <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOption.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOption.cs
new file mode 100644
index 000000000..fcb8f41b3
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOption.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+
+namespace Jellyfin.Database.Implementations.DbConfiguration;
+
+/// <summary>
+/// The custom value option for custom database providers.
+/// </summary>
+public class CustomDatabaseOption
+{
+ /// <summary>
+ /// Gets or sets the key of the value.
+ /// </summary>
+ public required string Key { get; set; }
+
+ /// <summary>
+ /// Gets or sets the value.
+ /// </summary>
+ public required string Value { get; set; }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOptions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOptions.cs
new file mode 100644
index 000000000..e2088704d
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/CustomDatabaseOptions.cs
@@ -0,0 +1,32 @@
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace Jellyfin.Database.Implementations.DbConfiguration;
+
+/// <summary>
+/// Defines the options for a custom database connector.
+/// </summary>
+public class CustomDatabaseOptions
+{
+ /// <summary>
+ /// Gets or sets the Plugin name to search for database providers.
+ /// </summary>
+ public required string PluginName { get; set; }
+
+ /// <summary>
+ /// Gets or sets the plugin assembly to search for providers.
+ /// </summary>
+ public required string PluginAssembly { get; set; }
+
+ /// <summary>
+ /// Gets or sets the connection string for the custom database provider.
+ /// </summary>
+ public required string ConnectionString { get; set; }
+
+ /// <summary>
+ /// Gets or sets the list of extra options for the custom provider.
+ /// </summary>
+#pragma warning disable CA2227 // Collection properties should be read only
+ public Collection<CustomDatabaseOption> Options { get; set; } = [];
+#pragma warning restore CA2227 // Collection properties should be read only
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs
index b481a106f..bc0cacf3c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseConfigurationOptions.cs
@@ -1,3 +1,5 @@
+using System.Collections.Generic;
+
namespace Jellyfin.Database.Implementations.DbConfiguration;
/// <summary>
@@ -9,4 +11,15 @@ public class DatabaseConfigurationOptions
/// Gets or Sets the type of database jellyfin should use.
/// </summary>
public required string DatabaseType { get; set; }
+
+ /// <summary>
+ /// Gets or sets the options required to use a custom database provider.
+ /// </summary>
+ public CustomDatabaseOptions? CustomProviderOptions { get; set; }
+
+ /// <summary>
+ /// Gets or Sets the kind of locking behavior jellyfin should perform. Possible options are "NoLock", "Pessimistic", "Optimistic".
+ /// Defaults to "NoLock".
+ /// </summary>
+ public DatabaseLockingBehaviorTypes LockingBehavior { get; set; }
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs
new file mode 100644
index 000000000..3b2a55802
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DbConfiguration/DatabaseLockingBehaviorTypes.cs
@@ -0,0 +1,22 @@
+namespace Jellyfin.Database.Implementations.DbConfiguration;
+
+/// <summary>
+/// Defines all possible methods for locking database access for concurrent queries.
+/// </summary>
+public enum DatabaseLockingBehaviorTypes
+{
+ /// <summary>
+ /// Defines that no explicit application level locking for reads and writes should be done and only provider specific locking should be relied on.
+ /// </summary>
+ NoLock = 0,
+
+ /// <summary>
+ /// Defines a behavior that always blocks all reads while any one write is done.
+ /// </summary>
+ Pessimistic = 1,
+
+ /// <summary>
+ /// Defines that all writes should be attempted and when fail should be retried.
+ /// </summary>
+ Optimistic = 2
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs
index a09a96317..d58466e5c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs
@@ -146,6 +146,8 @@ public class BaseItemEntity
public Guid? ParentId { get; set; }
+ public BaseItemEntity? DirectParent { get; set; }
+
public Guid? TopParentId { get; set; }
public Guid? SeasonId { get; set; }
@@ -168,6 +170,8 @@ public class BaseItemEntity
public ICollection<AncestorId>? Children { get; set; }
+ public ICollection<BaseItemEntity>? DirectChildren { get; set; }
+
public ICollection<BaseItemMetadataField>? LockedFields { get; set; }
public ICollection<BaseItemTrailerType>? TrailerTypes { get; set; }
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs
index 71d60fc25..cd14764e4 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemImageInfo.cs
@@ -22,7 +22,7 @@ public class BaseItemImageInfo
/// <summary>
/// Gets or Sets the time the image was last modified.
/// </summary>
- public DateTime DateModified { get; set; }
+ public DateTime? DateModified { get; set; }
/// <summary>
/// Gets or Sets the imagetype.
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs
index 06b290e4f..39b449553 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs
@@ -14,7 +14,6 @@ public class TrickplayInfo
/// <remarks>
/// Required.
/// </remarks>
- [JsonIgnore]
public Guid ItemId { get; set; }
/// <summary>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
index 4da7074ec..6c81fa729 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs
@@ -61,7 +61,6 @@ namespace Jellyfin.Database.Implementations.Entities
/// <remarks>
/// Identity, Indexed, Required.
/// </remarks>
- [JsonIgnore]
public Guid Id { get; set; }
/// <summary>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs
index cd8068661..3d8b01c2b 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/UserData.cs
@@ -69,6 +69,11 @@ public class UserData
public bool? Likes { get; set; }
/// <summary>
+ /// Gets or Sets the date the referenced <see cref="Item"/> has been deleted.
+ /// </summary>
+ public DateTime? RetentionDate { get; set; }
+
+ /// <summary>
/// Gets or sets the key.
/// </summary>
/// <value>The key.</value>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
index 566b521dd..27dbeaba6 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs
@@ -1,6 +1,8 @@
using System;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.DbConfiguration;
using Microsoft.EntityFrameworkCore;
namespace Jellyfin.Database.Implementations;
@@ -19,7 +21,8 @@ public interface IJellyfinDatabaseProvider
/// Initialises jellyfins EFCore database access.
/// </summary>
/// <param name="options">The EFCore database options.</param>
- void Initialise(DbContextOptionsBuilder options);
+ /// <param name="databaseConfiguration">The Jellyfin database options.</param>
+ void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration);
/// <summary>
/// Will be invoked when EFCore wants to build its model.
@@ -50,7 +53,7 @@ public interface IJellyfinDatabaseProvider
/// <summary>
/// Runs a full Database backup that can later be restored to.
/// </summary>
- /// <param name="cancellationToken">A cancelation token.</param>
+ /// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A key to identify the backup.</returns>
/// <exception cref="NotImplementedException">May throw an NotImplementException if this operation is not supported for this database.</exception>
Task<string> MigrationBackupFast(CancellationToken cancellationToken);
@@ -59,7 +62,22 @@ public interface IJellyfinDatabaseProvider
/// Restores a backup that has been previously created by <see cref="MigrationBackupFast(CancellationToken)"/>.
/// </summary>
/// <param name="key">The key to the backup from which the current database should be restored from.</param>
- /// <param name="cancellationToken">A cancelation token.</param>
+ /// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
Task RestoreBackupFast(string key, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Deletes a backup that has been previously created by <see cref="MigrationBackupFast(CancellationToken)"/>.
+ /// </summary>
+ /// <param name="key">The key to the backup which should be cleaned up.</param>
+ /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
+ Task DeleteBackup(string key);
+
+ /// <summary>
+ /// Removes all contents from the database.
+ /// </summary>
+ /// <param name="dbContext">The Database context.</param>
+ /// <param name="tableNames">The names of the tables to purge or null for all tables to be purged.</param>
+ /// <returns>A Task.</returns>
+ Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames);
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
index 3b619cce6..28c4972d2 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Jellyfin.Database.Implementations.csproj
@@ -10,7 +10,21 @@
<Compile Include="..\..\..\SharedVersion.cs" />
</ItemGroup>
+ <PropertyGroup>
+ <Authors>Jellyfin Contributors</Authors>
+ <PackageId>Jellyfin.Database.Implementations</PackageId>
+ <VersionPrefix>10.11.0</VersionPrefix>
+ <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
+ <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
+ </PropertyGroup>
+
+ <PropertyGroup Condition=" '$(Stability)'=='Unstable'">
+ <!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
+ <AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
+ </PropertyGroup>
+
<ItemGroup>
+ <PackageReference Include="Polly" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
index 35ad461ec..5163bff8b 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinDbContext.cs
@@ -1,9 +1,14 @@
using System;
+using System.Data.Common;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Entities.Security;
using Jellyfin.Database.Implementations.Interfaces;
+using Jellyfin.Database.Implementations.Locking;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Database.Implementations;
@@ -15,7 +20,8 @@ namespace Jellyfin.Database.Implementations;
/// <param name="options">The database context options.</param>
/// <param name="logger">Logger.</param>
/// <param name="jellyfinDatabaseProvider">The provider for the database engine specific operations.</param>
-public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILogger<JellyfinDbContext> logger, IJellyfinDatabaseProvider jellyfinDatabaseProvider) : DbContext(options)
+/// <param name="entityFrameworkCoreLocking">The locking behavior.</param>
+public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILogger<JellyfinDbContext> logger, IJellyfinDatabaseProvider jellyfinDatabaseProvider, IEntityFrameworkCoreLockingBehavior entityFrameworkCoreLocking) : DbContext(options)
{
/// <summary>
/// Gets the <see cref="DbSet{TEntity}"/> containing the access schedules.
@@ -247,19 +253,41 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
public DbSet<TrackMetadata> TrackMetadata => Set<TrackMetadata>();*/
/// <inheritdoc/>
- public override int SaveChanges()
+ public override async Task<int> SaveChangesAsync(
+ bool acceptAllChangesOnSuccess,
+ CancellationToken cancellationToken = default)
{
- foreach (var saveEntity in ChangeTracker.Entries()
- .Where(e => e.State == EntityState.Modified)
- .Select(entry => entry.Entity)
- .OfType<IHasConcurrencyToken>())
+ HandleConcurrencyToken();
+
+ try
{
- saveEntity.OnSavingChanges();
+ var result = -1;
+ await entityFrameworkCoreLocking.OnSaveChangesAsync(this, async () =>
+ {
+ result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken).ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ return result;
}
+ catch (Exception e)
+ {
+ logger.LogError(e, "Error trying to save changes.");
+ throw;
+ }
+ }
+
+ /// <inheritdoc/>
+ public override int SaveChanges(bool acceptAllChangesOnSuccess) // SaveChanges(bool) is beeing called by SaveChanges() with default to false.
+ {
+ HandleConcurrencyToken();
try
{
- return base.SaveChanges();
+ var result = -1;
+ entityFrameworkCoreLocking.OnSaveChanges(this, () =>
+ {
+ result = base.SaveChanges(acceptAllChangesOnSuccess);
+ });
+ return result;
}
catch (Exception e)
{
@@ -268,6 +296,17 @@ public class JellyfinDbContext(DbContextOptions<JellyfinDbContext> options, ILog
}
}
+ private void HandleConcurrencyToken()
+ {
+ foreach (var saveEntity in ChangeTracker.Entries()
+ .Where(e => e.State == EntityState.Modified)
+ .Select(entry => entry.Entity)
+ .OfType<IHasConcurrencyToken>())
+ {
+ saveEntity.OnSavingChanges();
+ }
+ }
+
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
new file mode 100644
index 000000000..f386e882e
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs
@@ -0,0 +1,194 @@
+#pragma warning disable RS0030 // Do not use banned APIs
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Reflection;
+using Jellyfin.Database.Implementations.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Database.Implementations;
+
+/// <summary>
+/// Contains a number of query related extensions.
+/// </summary>
+public static class JellyfinQueryHelperExtensions
+{
+ private static readonly MethodInfo _containsMethodGenericCache = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static).First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2);
+ private static readonly MethodInfo _efParameterInstruction = typeof(EF).GetMethod(nameof(EF.Parameter), BindingFlags.Public | BindingFlags.Static)!;
+ private static readonly ConcurrentDictionary<Type, MethodInfo> _containsQueryCache = new();
+
+ /// <summary>
+ /// Builds an optimised query checking one property against a list of values while maintaining an optimal query.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity.</typeparam>
+ /// <typeparam name="TProperty">The property type to compare.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <param name="oneOf">The list of items to check.</param>
+ /// <param name="property">Property expression.</param>
+ /// <returns>A Query.</returns>
+ public static IQueryable<TEntity> WhereOneOrMany<TEntity, TProperty>(this IQueryable<TEntity> query, IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
+ {
+ return query.Where(OneOrManyExpressionBuilder(oneOf, property));
+ }
+
+ /// <summary>
+ /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup.
+ /// </summary>
+ /// <param name="baseQuery">The source query.</param>
+ /// <param name="context">The database context.</param>
+ /// <param name="itemValueType">The type of item value to reference.</param>
+ /// <param name="referenceIds">The list of BaseItem ids to check matches.</param>
+ /// <param name="invert">If set an exclusion check is performed instead.</param>
+ /// <returns>A Query.</returns>
+ public static IQueryable<BaseItemEntity> WhereReferencedItem(
+ this IQueryable<BaseItemEntity> baseQuery,
+ JellyfinDbContext context,
+ ItemValueType itemValueType,
+ IList<Guid> referenceIds,
+ bool invert = false)
+ {
+ return baseQuery.Where(ReferencedItemFilterExpressionBuilder(context, itemValueType, referenceIds, invert));
+ }
+
+ /// <summary>
+ /// Builds a query that checks referenced ItemValues for a cross BaseItem lookup.
+ /// </summary>
+ /// <param name="baseQuery">The source query.</param>
+ /// <param name="context">The database context.</param>
+ /// <param name="itemValueTypes">The type of item value to reference.</param>
+ /// <param name="referenceIds">The list of BaseItem ids to check matches.</param>
+ /// <param name="invert">If set an exclusion check is performed instead.</param>
+ /// <returns>A Query.</returns>
+ public static IQueryable<BaseItemEntity> WhereReferencedItemMultipleTypes(
+ this IQueryable<BaseItemEntity> baseQuery,
+ JellyfinDbContext context,
+ IList<ItemValueType> itemValueTypes,
+ IList<Guid> referenceIds,
+ bool invert = false)
+ {
+ var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id);
+ var typeFilter = OneOrManyExpressionBuilder<ItemValue, ItemValueType>(itemValueTypes, iv => iv.Type);
+
+ return baseQuery.Where(item =>
+ context.ItemValues
+ .Where(typeFilter)
+ .Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (itemVal, map) => new { itemVal, map })
+ .Any(val =>
+ context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue)
+ && val.map.ItemId == item.Id) == EF.Constant(!invert));
+ }
+
+ /// <summary>
+ /// Builds a query expression that checks referenced ItemValues for a cross BaseItem lookup.
+ /// </summary>
+ /// <param name="context">The database context.</param>
+ /// <param name="itemValueType">The type of item value to reference.</param>
+ /// <param name="referenceIds">The list of BaseItem ids to check matches.</param>
+ /// <param name="invert">If set an exclusion check is performed instead.</param>
+ /// <returns>A Query.</returns>
+ public static Expression<Func<BaseItemEntity, bool>> ReferencedItemFilterExpressionBuilder(
+ this JellyfinDbContext context,
+ ItemValueType itemValueType,
+ IList<Guid> referenceIds,
+ bool invert = false)
+ {
+ // Well genre/artist/album etc items do not actually set the ItemValue of thier specitic types so we cannot match it that way.
+ /*
+ "(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@GenreIds and Type=2)))"
+ */
+
+ var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id);
+
+ return item =>
+ context.ItemValues
+ .Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (item, map) => new { item, map })
+ .Any(val =>
+ val.item.Type == itemValueType
+ && context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.item.CleanValue)
+ && val.map.ItemId == item.Id) == EF.Constant(!invert);
+ }
+
+ /// <summary>
+ /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity.</typeparam>
+ /// <typeparam name="TProperty">The property type to compare.</typeparam>
+ /// <param name="oneOf">The list of items to check.</param>
+ /// <param name="property">Property expression.</param>
+ /// <returns>A Query.</returns>
+ public static Expression<Func<TEntity, bool>> OneOrManyExpressionBuilder<TEntity, TProperty>(this IList<TProperty> oneOf, Expression<Func<TEntity, TProperty>> property)
+ {
+ var parameter = Expression.Parameter(typeof(TEntity), "item");
+ property = ParameterReplacer.Replace<Func<TEntity, TProperty>, Func<TEntity, TProperty>>(property, property.Parameters[0], parameter);
+ if (oneOf.Count == 1)
+ {
+ var value = oneOf[0];
+ if (typeof(TProperty).IsValueType)
+ {
+ return Expression.Lambda<Func<TEntity, bool>>(Expression.Equal(property.Body, Expression.Constant(value)), parameter);
+ }
+ else
+ {
+ return Expression.Lambda<Func<TEntity, bool>>(Expression.ReferenceEqual(property.Body, Expression.Constant(value)), parameter);
+ }
+ }
+
+ var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
+
+ if (oneOf.Count < 4) // arbitrary value choosen.
+ {
+ // if we have 3 or fewer values to check against its faster to do a IN(const,const,const) lookup
+ return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter);
+ }
+
+ return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter);
+ }
+
+ internal static class ParameterReplacer
+ {
+ // Produces an expression identical to 'expression'
+ // except with 'source' parameter replaced with 'target' expression.
+ internal static Expression<TOutput> Replace<TInput, TOutput>(
+ Expression<TInput> expression,
+ ParameterExpression source,
+ ParameterExpression target)
+ {
+ return new ParameterReplacerVisitor<TOutput>(source, target)
+ .VisitAndConvert(expression);
+ }
+
+ private sealed class ParameterReplacerVisitor<TOutput> : ExpressionVisitor
+ {
+ private readonly ParameterExpression _source;
+ private readonly ParameterExpression _target;
+
+ public ParameterReplacerVisitor(ParameterExpression source, ParameterExpression target)
+ {
+ _source = source;
+ _target = target;
+ }
+
+ internal Expression<TOutput> VisitAndConvert<T>(Expression<T> root)
+ {
+ return (Expression<TOutput>)VisitLambda(root);
+ }
+
+ protected override Expression VisitLambda<T>(Expression<T> node)
+ {
+ // Leave all parameters alone except the one we want to replace.
+ var parameters = node.Parameters.Select(p => p == _source ? _target : p);
+
+ return Expression.Lambda<TOutput>(Visit(node.Body), parameters);
+ }
+
+ protected override Expression VisitParameter(ParameterExpression node)
+ {
+ // Replace the source with the target, visit other params as usual.
+ return node == _source ? _target : base.VisitParameter(node);
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs
new file mode 100644
index 000000000..465c31212
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/IEntityFrameworkCoreLockingBehavior.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+/// <summary>
+/// Defines a jellyfin locking behavior that can be configured.
+/// </summary>
+public interface IEntityFrameworkCoreLockingBehavior
+{
+ /// <summary>
+ /// Provides access to the builder to setup any connection related locking behavior.
+ /// </summary>
+ /// <param name="optionsBuilder">The options builder.</param>
+ void Initialise(DbContextOptionsBuilder optionsBuilder);
+
+ /// <summary>
+ /// Will be invoked when changes should be saved in the current locking behavior.
+ /// </summary>
+ /// <param name="context">The database context invoking the action.</param>
+ /// <param name="saveChanges">Callback for performing the actual save changes.</param>
+ void OnSaveChanges(JellyfinDbContext context, Action saveChanges);
+
+ /// <summary>
+ /// Will be invoked when changes should be saved in the current locking behavior.
+ /// </summary>
+ /// <param name="context">The database context invoking the action.</param>
+ /// <param name="saveChanges">Callback for performing the actual save changes.</param>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges);
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs
new file mode 100644
index 000000000..3b654f4c4
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/NoLockBehavior.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+/// <summary>
+/// Default lock behavior. Defines no explicit application locking behavior.
+/// </summary>
+public class NoLockBehavior : IEntityFrameworkCoreLockingBehavior
+{
+ private readonly ILogger<NoLockBehavior> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NoLockBehavior"/> class.
+ /// </summary>
+ /// <param name="logger">The Application logger.</param>
+ public NoLockBehavior(ILogger<NoLockBehavior> logger)
+ {
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
+ {
+ saveChanges();
+ }
+
+ /// <inheritdoc/>
+ public void Initialise(DbContextOptionsBuilder optionsBuilder)
+ {
+ _logger.LogInformation("The database locking mode has been set to: NoLock.");
+ }
+
+ /// <inheritdoc/>
+ public async Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges)
+ {
+ await saveChanges().ConfigureAwait(false);
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
new file mode 100644
index 000000000..7bcc7eeca
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/OptimisticLockBehavior.cs
@@ -0,0 +1,161 @@
+using System;
+using System.Data.Common;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Logging;
+using Polly;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+/// <summary>
+/// Defines a locking mechanism that will retry any write operation for a few times.
+/// </summary>
+public class OptimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
+{
+ private readonly Policy _writePolicy;
+ private readonly AsyncPolicy _writeAsyncPolicy;
+ private readonly ILogger<OptimisticLockBehavior> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="OptimisticLockBehavior"/> class.
+ /// </summary>
+ /// <param name="logger">The application logger.</param>
+ public OptimisticLockBehavior(ILogger<OptimisticLockBehavior> logger)
+ {
+ TimeSpan[] sleepDurations = [
+ TimeSpan.FromMilliseconds(50),
+ TimeSpan.FromMilliseconds(50),
+ TimeSpan.FromMilliseconds(50),
+ TimeSpan.FromMilliseconds(50),
+ TimeSpan.FromMilliseconds(250),
+ TimeSpan.FromMilliseconds(250),
+ TimeSpan.FromMilliseconds(250),
+ TimeSpan.FromMilliseconds(150),
+ TimeSpan.FromMilliseconds(150),
+ TimeSpan.FromMilliseconds(150),
+ TimeSpan.FromMilliseconds(500),
+ TimeSpan.FromMilliseconds(150),
+ TimeSpan.FromMilliseconds(500),
+ TimeSpan.FromMilliseconds(150),
+ TimeSpan.FromSeconds(3)
+ ];
+
+ Func<int, Context, TimeSpan> backoffProvider = (index, context) =>
+ {
+ var backoff = sleepDurations[index];
+ return backoff + TimeSpan.FromMilliseconds(RandomNumberGenerator.GetInt32(0, (int)(backoff.TotalMilliseconds * .5)));
+ };
+
+ _logger = logger;
+ _writePolicy = Policy
+ .HandleInner<Exception>(e =>
+ e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase) ||
+ e.Message.Contains("database table is locked", StringComparison.InvariantCultureIgnoreCase))
+ .WaitAndRetry(sleepDurations.Length, backoffProvider, RetryHandle);
+ _writeAsyncPolicy = Policy
+ .HandleInner<Exception>(e =>
+ e.Message.Contains("database is locked", StringComparison.InvariantCultureIgnoreCase) ||
+ e.Message.Contains("database table is locked", StringComparison.InvariantCultureIgnoreCase))
+ .WaitAndRetryAsync(sleepDurations.Length, backoffProvider, RetryHandle);
+
+ void RetryHandle(Exception exception, TimeSpan timespan, int retryNo, Context context)
+ {
+ if (retryNo < sleepDurations.Length)
+ {
+ _logger.LogWarning("Operation failed retry {RetryNo}", retryNo);
+ }
+ else
+ {
+ _logger.LogError(exception, "Operation failed retry {RetryNo}", retryNo);
+ }
+ }
+ }
+
+ /// <inheritdoc/>
+ public void Initialise(DbContextOptionsBuilder optionsBuilder)
+ {
+ _logger.LogInformation("The database locking mode has been set to: Optimistic.");
+ optionsBuilder.AddInterceptors(new RetryInterceptor(_writeAsyncPolicy, _writePolicy));
+ optionsBuilder.AddInterceptors(new TransactionLockingInterceptor(_writeAsyncPolicy, _writePolicy));
+ }
+
+ /// <inheritdoc/>
+ public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
+ {
+ _writePolicy.ExecuteAndCapture(saveChanges);
+ }
+
+ /// <inheritdoc/>
+ public async Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges)
+ {
+ await _writeAsyncPolicy.ExecuteAndCaptureAsync(saveChanges).ConfigureAwait(false);
+ }
+
+ private sealed class TransactionLockingInterceptor : DbTransactionInterceptor
+ {
+ private readonly AsyncPolicy _asyncRetryPolicy;
+ private readonly Policy _retryPolicy;
+
+ public TransactionLockingInterceptor(AsyncPolicy asyncRetryPolicy, Policy retryPolicy)
+ {
+ _asyncRetryPolicy = asyncRetryPolicy;
+ _retryPolicy = retryPolicy;
+ }
+
+ public override InterceptionResult<DbTransaction> TransactionStarting(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result)
+ {
+ return InterceptionResult<DbTransaction>.SuppressWithResult(_retryPolicy.Execute(() => connection.BeginTransaction(eventData.IsolationLevel)));
+ }
+
+ public override async ValueTask<InterceptionResult<DbTransaction>> TransactionStartingAsync(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult<DbTransaction>.SuppressWithResult(await _asyncRetryPolicy.ExecuteAsync(async () => await connection.BeginTransactionAsync(eventData.IsolationLevel, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false));
+ }
+ }
+
+ private sealed class RetryInterceptor : DbCommandInterceptor
+ {
+ private readonly AsyncPolicy _asyncRetryPolicy;
+ private readonly Policy _retryPolicy;
+
+ public RetryInterceptor(AsyncPolicy asyncRetryPolicy, Policy retryPolicy)
+ {
+ _asyncRetryPolicy = asyncRetryPolicy;
+ _retryPolicy = retryPolicy;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ return InterceptionResult<int>.SuppressWithResult(_retryPolicy.Execute(command.ExecuteNonQuery));
+ }
+
+ public override async ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult<int>.SuppressWithResult(await _asyncRetryPolicy.ExecuteAsync(async () => await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false));
+ }
+
+ public override InterceptionResult<object> ScalarExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<object> result)
+ {
+ return InterceptionResult<object>.SuppressWithResult(_retryPolicy.Execute(() => command.ExecuteScalar()!));
+ }
+
+ public override async ValueTask<InterceptionResult<object>> ScalarExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<object> result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult<object>.SuppressWithResult((await _asyncRetryPolicy.ExecuteAsync(async () => await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false)!).ConfigureAwait(false))!);
+ }
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ return InterceptionResult<DbDataReader>.SuppressWithResult(_retryPolicy.Execute(command.ExecuteReader));
+ }
+
+ public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
+ {
+ return InterceptionResult<DbDataReader>.SuppressWithResult(await _asyncRetryPolicy.ExecuteAsync(async () => await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false)).ConfigureAwait(false));
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
new file mode 100644
index 000000000..2d6bc6902
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Locking/PessimisticLockBehavior.cs
@@ -0,0 +1,296 @@
+#pragma warning disable MT1013 // Releasing lock without guarantee of execution
+#pragma warning disable MT1012 // Acquiring lock without guarantee of releasing
+
+using System;
+using System.Data;
+using System.Data.Common;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Database.Implementations.Locking;
+
+/// <summary>
+/// A locking behavior that will always block any operation while a write is requested. Mimicks the old SqliteRepository behavior.
+/// </summary>
+public class PessimisticLockBehavior : IEntityFrameworkCoreLockingBehavior
+{
+ private readonly ILogger<PessimisticLockBehavior> _logger;
+ private readonly ILoggerFactory _loggerFactory;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PessimisticLockBehavior"/> class.
+ /// </summary>
+ /// <param name="logger">The application logger.</param>
+ /// <param name="loggerFactory">The logger factory.</param>
+ public PessimisticLockBehavior(ILogger<PessimisticLockBehavior> logger, ILoggerFactory loggerFactory)
+ {
+ _logger = logger;
+ _loggerFactory = loggerFactory;
+ }
+
+ private static ReaderWriterLockSlim DatabaseLock { get; } = new(LockRecursionPolicy.SupportsRecursion);
+
+ /// <inheritdoc/>
+ public void OnSaveChanges(JellyfinDbContext context, Action saveChanges)
+ {
+ using (DbLock.EnterWrite(_logger))
+ {
+ saveChanges();
+ }
+ }
+
+ /// <inheritdoc/>
+ public void Initialise(DbContextOptionsBuilder optionsBuilder)
+ {
+ _logger.LogInformation("The database locking mode has been set to: Pessimistic.");
+ optionsBuilder.AddInterceptors(new CommandLockingInterceptor(_loggerFactory.CreateLogger<CommandLockingInterceptor>()));
+ optionsBuilder.AddInterceptors(new TransactionLockingInterceptor(_loggerFactory.CreateLogger<TransactionLockingInterceptor>()));
+ }
+
+ /// <inheritdoc/>
+ public async Task OnSaveChangesAsync(JellyfinDbContext context, Func<Task> saveChanges)
+ {
+ using (DbLock.EnterWrite(_logger))
+ {
+ await saveChanges().ConfigureAwait(false);
+ }
+ }
+
+ private sealed class TransactionLockingInterceptor : DbTransactionInterceptor
+ {
+ private readonly ILogger _logger;
+
+ public TransactionLockingInterceptor(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public override InterceptionResult<DbTransaction> TransactionStarting(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result)
+ {
+ DbLock.BeginWriteLock(_logger);
+
+ return base.TransactionStarting(connection, eventData, result);
+ }
+
+ public override ValueTask<InterceptionResult<DbTransaction>> TransactionStartingAsync(DbConnection connection, TransactionStartingEventData eventData, InterceptionResult<DbTransaction> result, CancellationToken cancellationToken = default)
+ {
+ DbLock.BeginWriteLock(_logger);
+
+ return base.TransactionStartingAsync(connection, eventData, result, cancellationToken);
+ }
+
+ public override void TransactionCommitted(DbTransaction transaction, TransactionEndEventData eventData)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ base.TransactionCommitted(transaction, eventData);
+ }
+
+ public override Task TransactionCommittedAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ return base.TransactionCommittedAsync(transaction, eventData, cancellationToken);
+ }
+
+ public override void TransactionFailed(DbTransaction transaction, TransactionErrorEventData eventData)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ base.TransactionFailed(transaction, eventData);
+ }
+
+ public override Task TransactionFailedAsync(DbTransaction transaction, TransactionErrorEventData eventData, CancellationToken cancellationToken = default)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ return base.TransactionFailedAsync(transaction, eventData, cancellationToken);
+ }
+
+ public override void TransactionRolledBack(DbTransaction transaction, TransactionEndEventData eventData)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ base.TransactionRolledBack(transaction, eventData);
+ }
+
+ public override Task TransactionRolledBackAsync(DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default)
+ {
+ DbLock.EndWriteLock(_logger);
+
+ return base.TransactionRolledBackAsync(transaction, eventData, cancellationToken);
+ }
+ }
+
+ /// <summary>
+ /// Adds strict read/write locking.
+ /// </summary>
+ private sealed class CommandLockingInterceptor : DbCommandInterceptor
+ {
+ private readonly ILogger _logger;
+
+ public CommandLockingInterceptor(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public override InterceptionResult<int> NonQueryExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<int> result)
+ {
+ using (DbLock.EnterWrite(_logger, command))
+ {
+ return InterceptionResult<int>.SuppressWithResult(command.ExecuteNonQuery());
+ }
+ }
+
+ public override async ValueTask<InterceptionResult<int>> NonQueryExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
+ {
+ using (DbLock.EnterWrite(_logger, command))
+ {
+ return InterceptionResult<int>.SuppressWithResult(await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false));
+ }
+ }
+
+ public override InterceptionResult<object> ScalarExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<object> result)
+ {
+ using (DbLock.EnterRead(_logger))
+ {
+ return InterceptionResult<object>.SuppressWithResult(command.ExecuteScalar()!);
+ }
+ }
+
+ public override async ValueTask<InterceptionResult<object>> ScalarExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<object> result, CancellationToken cancellationToken = default)
+ {
+ using (DbLock.EnterRead(_logger))
+ {
+ return InterceptionResult<object>.SuppressWithResult((await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false))!);
+ }
+ }
+
+ public override InterceptionResult<DbDataReader> ReaderExecuting(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
+ {
+ using (DbLock.EnterRead(_logger))
+ {
+ return InterceptionResult<DbDataReader>.SuppressWithResult(command.ExecuteReader()!);
+ }
+ }
+
+ public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result, CancellationToken cancellationToken = default)
+ {
+ using (DbLock.EnterRead(_logger))
+ {
+ return InterceptionResult<DbDataReader>.SuppressWithResult(await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false));
+ }
+ }
+ }
+
+ private sealed class DbLock : IDisposable
+ {
+ private readonly Action? _action;
+ private bool _disposed;
+
+ private static readonly IDisposable _noLock = new DbLock(null) { _disposed = true };
+ private static (string Command, Guid Id, DateTimeOffset QueryDate, bool Printed) _blockQuery;
+
+ public DbLock(Action? action = null)
+ {
+ _action = action;
+ }
+
+#pragma warning disable IDISP015 // Member should not return created and cached instance
+ public static IDisposable EnterWrite(ILogger logger, IDbCommand? command = null, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+#pragma warning restore IDISP015 // Member should not return created and cached instance
+ {
+ logger.LogTrace("Enter Write for {Caller}:{Line}", callerMemberName, callerNo);
+ if (DatabaseLock.IsWriteLockHeld)
+ {
+ logger.LogTrace("Write Held {Caller}:{Line}", callerMemberName, callerNo);
+ return _noLock;
+ }
+
+ BeginWriteLock(logger, command, callerMemberName, callerNo);
+ return new DbLock(() =>
+ {
+ EndWriteLock(logger, callerMemberName, callerNo);
+ });
+ }
+
+#pragma warning disable IDISP015 // Member should not return created and cached instance
+ public static IDisposable EnterRead(ILogger logger, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+#pragma warning restore IDISP015 // Member should not return created and cached instance
+ {
+ logger.LogTrace("Enter Read {Caller}:{Line}", callerMemberName, callerNo);
+ if (DatabaseLock.IsWriteLockHeld)
+ {
+ logger.LogTrace("Write Held {Caller}:{Line}", callerMemberName, callerNo);
+ return _noLock;
+ }
+
+ BeginReadLock(logger, callerMemberName, callerNo);
+ return new DbLock(() =>
+ {
+ ExitReadLock(logger, callerMemberName, callerNo);
+ });
+ }
+
+ public static void BeginWriteLock(ILogger logger, IDbCommand? command = null, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+ {
+ logger.LogTrace("Aquire Write {Caller}:{Line}", callerMemberName, callerNo);
+ if (!DatabaseLock.TryEnterWriteLock(TimeSpan.FromMilliseconds(1000)))
+ {
+ var blockingQuery = _blockQuery;
+ if (!blockingQuery.Printed)
+ {
+ _blockQuery = (blockingQuery.Command, blockingQuery.Id, blockingQuery.QueryDate, true);
+ logger.LogInformation("QueryLock: {Id} --- {Query}", blockingQuery.Id, blockingQuery.Command);
+ }
+
+ logger.LogInformation("Query congestion detected: '{Id}' since '{Date}'", blockingQuery.Id, blockingQuery.QueryDate);
+
+ DatabaseLock.EnterWriteLock();
+
+ logger.LogInformation("Query congestion cleared: '{Id}' for '{Date}'", blockingQuery.Id, DateTimeOffset.Now - blockingQuery.QueryDate);
+ }
+
+ _blockQuery = (command?.CommandText ?? "Transaction", Guid.NewGuid(), DateTimeOffset.Now, false);
+
+ logger.LogTrace("Write Aquired {Caller}:{Line}", callerMemberName, callerNo);
+ }
+
+ public static void BeginReadLock(ILogger logger, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+ {
+ logger.LogTrace("Aquire Write {Caller}:{Line}", callerMemberName, callerNo);
+ DatabaseLock.EnterReadLock();
+ logger.LogTrace("Read Aquired {Caller}:{Line}", callerMemberName, callerNo);
+ }
+
+ public static void EndWriteLock(ILogger logger, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+ {
+ logger.LogTrace("Release Write {Caller}:{Line}", callerMemberName, callerNo);
+ DatabaseLock.ExitWriteLock();
+ }
+
+ public static void ExitReadLock(ILogger logger, [CallerMemberName] string? callerMemberName = null, [CallerLineNumber] int? callerNo = null)
+ {
+ logger.LogTrace("Release Read {Caller}:{Line}", callerMemberName, callerNo);
+ DatabaseLock.ExitReadLock();
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ if (_action is not null)
+ {
+ _action();
+ }
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs
index 4a76113bf..6fccfd976 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/BaseItemConfiguration.cs
@@ -1,3 +1,4 @@
+using System;
using Jellyfin.Database.Implementations.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@@ -26,6 +27,7 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
builder.HasMany(e => e.Provider);
builder.HasMany(e => e.Parents);
builder.HasMany(e => e.Children);
+ builder.HasMany(e => e.DirectChildren).WithOne(e => e.DirectParent).HasForeignKey(e => e.ParentId).OnDelete(DeleteBehavior.Cascade);
builder.HasMany(e => e.LockedFields);
builder.HasMany(e => e.TrailerTypes);
builder.HasMany(e => e.Images);
@@ -53,5 +55,12 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
builder.HasIndex(e => new { e.IsFolder, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey, e.DateCreated });
// resume
builder.HasIndex(e => new { e.MediaType, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey });
+
+ builder.HasData(new BaseItemEntity()
+ {
+ Id = Guid.Parse("00000000-0000-0000-0000-000000000001"),
+ Type = "PLACEHOLDER",
+ Name = "This is a placeholder item for UserData that has been detacted from its original item",
+ });
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs
index 5e3ab4443..f7694aeda 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PeopleBaseItemMapConfiguration.cs
@@ -12,7 +12,7 @@ public class PeopleBaseItemMapConfiguration : IEntityTypeConfiguration<PeopleBas
/// <inheritdoc/>
public void Configure(EntityTypeBuilder<PeopleBaseItemMap> builder)
{
- builder.HasKey(e => new { e.ItemId, e.PeopleId });
+ builder.HasKey(e => new { e.ItemId, e.PeopleId, e.Role });
builder.HasIndex(e => new { e.ItemId, e.SortOrder });
builder.HasIndex(e => new { e.ItemId, e.ListOrder });
builder.HasOne(e => e.Item);
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs
index 47604d321..e7b436293 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/UserDataConfiguration.cs
@@ -17,6 +17,6 @@ public class UserDataConfiguration : IEntityTypeConfiguration<UserData>
builder.HasIndex(d => new { d.ItemId, d.UserId, d.PlaybackPositionTicks });
builder.HasIndex(d => new { d.ItemId, d.UserId, d.IsFavorite });
builder.HasIndex(d => new { d.ItemId, d.UserId, d.LastPlayedDate });
- builder.HasOne(e => e.Item);
+ builder.HasOne(e => e.Item).WithMany(e => e.UserData);
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ProgressablePartitionReporting.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ProgressablePartitionReporting.cs
new file mode 100644
index 000000000..7654dd3c5
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ProgressablePartitionReporting.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Diagnostics;
+using System.Linq;
+
+namespace Jellyfin.Database.Implementations;
+
+/// <summary>
+/// Wrapper for progress reporting on Partition helpers.
+/// </summary>
+/// <typeparam name="TEntity">The entity to load.</typeparam>
+public class ProgressablePartitionReporting<TEntity>
+{
+ private readonly IOrderedQueryable<TEntity> _source;
+
+ private readonly Stopwatch _partitionTime = new();
+
+ private readonly Stopwatch _itemTime = new();
+
+ internal ProgressablePartitionReporting(IOrderedQueryable<TEntity> source)
+ {
+ _source = source;
+ }
+
+ internal Action<TEntity, int, int>? OnBeginItem { get; set; }
+
+ internal Action<int>? OnBeginPartition { get; set; }
+
+ internal Action<TEntity, int, int, TimeSpan>? OnEndItem { get; set; }
+
+ internal Action<int, TimeSpan>? OnEndPartition { get; set; }
+
+ internal IOrderedQueryable<TEntity> Source => _source;
+
+ internal void BeginItem(TEntity entity, int iteration, int itemIndex)
+ {
+ _itemTime.Restart();
+ OnBeginItem?.Invoke(entity, iteration, itemIndex);
+ }
+
+ internal void BeginPartition(int iteration)
+ {
+ _partitionTime.Restart();
+ OnBeginPartition?.Invoke(iteration);
+ }
+
+ internal void EndItem(TEntity entity, int iteration, int itemIndex)
+ {
+ OnEndItem?.Invoke(entity, iteration, itemIndex, _itemTime.Elapsed);
+ }
+
+ internal void EndPartition(int iteration)
+ {
+ OnEndPartition?.Invoke(iteration, _partitionTime.Elapsed);
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs
new file mode 100644
index 000000000..c20dfeeb5
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/QueryPartitionHelpers.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jellyfin.Database.Implementations;
+
+/// <summary>
+/// Contains helpers to partition EFCore queries.
+/// </summary>
+public static class QueryPartitionHelpers
+{
+ /// <summary>
+ /// Adds a callback to any directly following calls of Partition for every partition thats been invoked.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <param name="beginPartition">The callback invoked for partition before enumerating items.</param>
+ /// <param name="endPartition">The callback invoked for partition after enumerating items.</param>
+ /// <returns>A queryable that can be used to partition.</returns>
+ public static ProgressablePartitionReporting<TEntity> WithPartitionProgress<TEntity>(this IOrderedQueryable<TEntity> query, Action<int>? beginPartition = null, Action<int, TimeSpan>? endPartition = null)
+ {
+ var progressable = new ProgressablePartitionReporting<TEntity>(query);
+ progressable.OnBeginPartition = beginPartition;
+ progressable.OnEndPartition = endPartition;
+ return progressable;
+ }
+
+ /// <summary>
+ /// Adds a callback to any directly following calls of Partition for every item thats been invoked.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <param name="beginItem">The callback invoked for each item before processing.</param>
+ /// <param name="endItem">The callback invoked for each item after processing.</param>
+ /// <returns>A queryable that can be used to partition.</returns>
+ public static ProgressablePartitionReporting<TEntity> WithItemProgress<TEntity>(this IOrderedQueryable<TEntity> query, Action<TEntity, int, int>? beginItem = null, Action<TEntity, int, int, TimeSpan>? endItem = null)
+ {
+ var progressable = new ProgressablePartitionReporting<TEntity>(query);
+ progressable.OnBeginItem = beginItem;
+ progressable.OnEndItem = endItem;
+ return progressable;
+ }
+
+ /// <summary>
+ /// Adds a callback to any directly following calls of Partition for every partition thats been invoked.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="progressable">The source query.</param>
+ /// <param name="beginPartition">The callback invoked for partition before enumerating items.</param>
+ /// <param name="endPartition">The callback invoked for partition after enumerating items.</param>
+ /// <returns>A queryable that can be used to partition.</returns>
+ public static ProgressablePartitionReporting<TEntity> WithPartitionProgress<TEntity>(this ProgressablePartitionReporting<TEntity> progressable, Action<int>? beginPartition = null, Action<int, TimeSpan>? endPartition = null)
+ {
+ progressable.OnBeginPartition = beginPartition;
+ progressable.OnEndPartition = endPartition;
+ return progressable;
+ }
+
+ /// <summary>
+ /// Adds a callback to any directly following calls of Partition for every item thats been invoked.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="progressable">The source query.</param>
+ /// <param name="beginItem">The callback invoked for each item before processing.</param>
+ /// <param name="endItem">The callback invoked for each item after processing.</param>
+ /// <returns>A queryable that can be used to partition.</returns>
+ public static ProgressablePartitionReporting<TEntity> WithItemProgress<TEntity>(this ProgressablePartitionReporting<TEntity> progressable, Action<TEntity, int, int>? beginItem = null, Action<TEntity, int, int, TimeSpan>? endItem = null)
+ {
+ progressable.OnBeginItem = beginItem;
+ progressable.OnEndItem = endItem;
+ return progressable;
+ }
+
+ /// <summary>
+ /// Enumerates the source query by loading the entities in partitions in a lazy manner reading each item from the database as its requested.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="partitionInfo">The source query.</param>
+ /// <param name="partitionSize">The number of elements to load per partition.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A enumerable representing the whole of the query.</returns>
+ public static async IAsyncEnumerable<TEntity> PartitionAsync<TEntity>(this ProgressablePartitionReporting<TEntity> partitionInfo, int partitionSize, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await foreach (var item in partitionInfo.Source.PartitionAsync(partitionSize, partitionInfo, cancellationToken).ConfigureAwait(false))
+ {
+ yield return item;
+ }
+ }
+
+ /// <summary>
+ /// Enumerates the source query by loading the entities in partitions directly into memory.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="partitionInfo">The source query.</param>
+ /// <param name="partitionSize">The number of elements to load per partition.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A enumerable representing the whole of the query.</returns>
+ public static async IAsyncEnumerable<TEntity> PartitionEagerAsync<TEntity>(this ProgressablePartitionReporting<TEntity> partitionInfo, int partitionSize, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await foreach (var item in partitionInfo.Source.PartitionEagerAsync(partitionSize, partitionInfo, cancellationToken).ConfigureAwait(false))
+ {
+ yield return item;
+ }
+ }
+
+ /// <summary>
+ /// Enumerates the source query by loading the entities in partitions in a lazy manner reading each item from the database as its requested.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <param name="partitionSize">The number of elements to load per partition.</param>
+ /// <param name="progressablePartition">Reporting helper.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A enumerable representing the whole of the query.</returns>
+ public static async IAsyncEnumerable<TEntity> PartitionAsync<TEntity>(
+ this IOrderedQueryable<TEntity> query,
+ int partitionSize,
+ ProgressablePartitionReporting<TEntity>? progressablePartition = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var iterator = 0;
+ int itemCounter;
+ do
+ {
+ progressablePartition?.BeginPartition(iterator);
+ itemCounter = 0;
+ await foreach (var item in query
+ .Skip(partitionSize * iterator)
+ .Take(partitionSize)
+ .AsAsyncEnumerable()
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ progressablePartition?.BeginItem(item, iterator, itemCounter);
+ yield return item;
+ progressablePartition?.EndItem(item, iterator, itemCounter);
+ itemCounter++;
+ }
+
+ progressablePartition?.EndPartition(iterator);
+ iterator++;
+ } while (itemCounter == partitionSize && !cancellationToken.IsCancellationRequested);
+ }
+
+ /// <summary>
+ /// Enumerates the source query by loading the entities in partitions directly into memory.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <param name="partitionSize">The number of elements to load per partition.</param>
+ /// <param name="progressablePartition">Reporting helper.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A enumerable representing the whole of the query.</returns>
+ public static async IAsyncEnumerable<TEntity> PartitionEagerAsync<TEntity>(
+ this IOrderedQueryable<TEntity> query,
+ int partitionSize,
+ ProgressablePartitionReporting<TEntity>? progressablePartition = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var iterator = 0;
+ int itemCounter;
+ var items = ArrayPool<TEntity>.Shared.Rent(partitionSize);
+ try
+ {
+ do
+ {
+ progressablePartition?.BeginPartition(iterator);
+ itemCounter = 0;
+ await foreach (var item in query
+ .Skip(partitionSize * iterator)
+ .Take(partitionSize)
+ .AsAsyncEnumerable()
+ .WithCancellation(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ items[itemCounter++] = item;
+ }
+
+ for (int i = 0; i < itemCounter; i++)
+ {
+ progressablePartition?.BeginItem(items[i], iterator, itemCounter);
+ yield return items[i];
+ progressablePartition?.EndItem(items[i], iterator, itemCounter);
+ }
+
+ progressablePartition?.EndPartition(iterator);
+ iterator++;
+ } while (itemCounter == partitionSize && !cancellationToken.IsCancellationRequested);
+ }
+ finally
+ {
+ ArrayPool<TEntity>.Shared.Return(items);
+ }
+ }
+
+ /// <summary>
+ /// Adds an Index to the enumeration of the async enumerable.
+ /// </summary>
+ /// <typeparam name="TEntity">The entity to load.</typeparam>
+ /// <param name="query">The source query.</param>
+ /// <returns>The source list with an index added.</returns>
+ public static async IAsyncEnumerable<(TEntity Item, int Index)> WithIndex<TEntity>(this IAsyncEnumerable<TEntity> query)
+ {
+ var index = 0;
+ await foreach (var item in query.ConfigureAwait(false))
+ {
+ yield return (item, index++);
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.Designer.cs
new file mode 100644
index 000000000..253e67e20
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.Designer.cs
@@ -0,0 +1,1693 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20250609115616_DetachUserDataInsteadOfDelete")]
+ partial class DetachUserDataInsteadOfDelete
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.5");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Album")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property<float?>("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property<string>("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Data")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExtraIds")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property<float?>("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property<string>("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property<string>("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property<long?>("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property<long?>("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<byte[]>("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ProviderValue", "ItemId");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("AverageFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("BitDepth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BlPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ChannelLayout")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("DvBlSignalCompatibilityId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvProfile")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMajor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMinor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("ElPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("Hdr10PlusPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAnamorphic")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAvc")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsExternal")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsHearingImpaired")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsInterlaced")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PixelFormat")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Profile")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("RealFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("RefFrames")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Rotation")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RpuPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SampleRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.HasIndex("StreamIndex");
+
+ b.HasIndex("StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType", "Language");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId");
+
+ b.HasIndex("PeopleId");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId");
+
+ b.HasIndex("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.ToTable("DeviceOptions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Bandwidth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Interval")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ThumbnailCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileHeight")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileWidth")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "Width");
+
+ b.ToTable("TrickplayInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CastReceiverId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFavorite")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastPlayedDate")
+ .HasColumnType("TEXT");
+
+ b.Property<bool?>("Likes")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("PlaybackPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property<double?>("Rating")
+ .HasColumnType("REAL");
+
+ b.Property<DateTimeOffset?>("RetentionDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Parents")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("Children")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("Parents");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences");
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs
new file mode 100644
index 000000000..2935a608d
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250609115616_DetachUserDataInsteadOfDelete.cs
@@ -0,0 +1,39 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ /// <inheritdoc />
+ public partial class DetachUserDataInsteadOfDelete : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<DateTimeOffset>(
+ name: "RetentionDate",
+ table: "UserData",
+ type: "TEXT",
+ nullable: true);
+
+ migrationBuilder.InsertData(
+ table: "BaseItems",
+ columns: new[] { "Id", "Album", "AlbumArtists", "Artists", "Audio", "ChannelId", "CleanName", "CommunityRating", "CriticRating", "CustomRating", "Data", "DateCreated", "DateLastMediaAdded", "DateLastRefreshed", "DateLastSaved", "DateModified", "EndDate", "EpisodeTitle", "ExternalId", "ExternalSeriesId", "ExternalServiceId", "ExtraIds", "ExtraType", "ForcedSortName", "Genres", "Height", "IndexNumber", "InheritedParentalRatingSubValue", "InheritedParentalRatingValue", "IsFolder", "IsInMixedFolder", "IsLocked", "IsMovie", "IsRepeat", "IsSeries", "IsVirtualItem", "LUFS", "MediaType", "Name", "NormalizationGain", "OfficialRating", "OriginalTitle", "Overview", "OwnerId", "ParentId", "ParentIndexNumber", "Path", "PreferredMetadataCountryCode", "PreferredMetadataLanguage", "PremiereDate", "PresentationUniqueKey", "PrimaryVersionId", "ProductionLocations", "ProductionYear", "RunTimeTicks", "SeasonId", "SeasonName", "SeriesId", "SeriesName", "SeriesPresentationUniqueKey", "ShowId", "Size", "SortName", "StartDate", "Studios", "Tagline", "Tags", "TopParentId", "TotalBitrate", "Type", "UnratedType", "Width" },
+ values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, false, false, false, false, false, false, false, null, null, "This is a placeholder item for UserData that has been detacted from its original item", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, "PLACEHOLDER", null, null });
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "RetentionDate",
+ table: "UserData");
+
+ migrationBuilder.DeleteData(
+ table: "BaseItems",
+ keyColumn: "Id",
+ keyValue: new Guid("00000000-0000-0000-0000-000000000001"));
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.Designer.cs
new file mode 100644
index 000000000..a0622c14d
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.Designer.cs
@@ -0,0 +1,1709 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20250622170802_BaseItemImageInfoDateModifiedNullable")]
+ partial class BaseItemImageInfoDateModifiedNullable
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Album")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property<float?>("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property<string>("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Data")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExtraIds")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property<float?>("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property<string>("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property<string>("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property<long?>("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property<long?>("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detacted from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<byte[]>("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ProviderValue", "ItemId");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("AverageFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("BitDepth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BlPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ChannelLayout")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("DvBlSignalCompatibilityId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvProfile")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMajor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMinor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("ElPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("Hdr10PlusPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAnamorphic")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAvc")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsExternal")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsHearingImpaired")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsInterlaced")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PixelFormat")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Profile")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("RealFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("RefFrames")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Rotation")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RpuPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SampleRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.HasIndex("StreamIndex");
+
+ b.HasIndex("StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType", "Language");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId");
+
+ b.HasIndex("PeopleId");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId");
+
+ b.HasIndex("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.ToTable("DeviceOptions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Bandwidth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Interval")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ThumbnailCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileHeight")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileWidth")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "Width");
+
+ b.ToTable("TrickplayInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CastReceiverId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFavorite")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastPlayedDate")
+ .HasColumnType("TEXT");
+
+ b.Property<bool?>("Likes")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("PlaybackPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property<double?>("Rating")
+ .HasColumnType("REAL");
+
+ b.Property<DateTime?>("RetentionDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Parents")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("Children")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("Parents");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences");
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs
new file mode 100644
index 000000000..bce6029d5
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250622170802_BaseItemImageInfoDateModifiedNullable.cs
@@ -0,0 +1,37 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ /// <inheritdoc />
+ public partial class BaseItemImageInfoDateModifiedNullable : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "DateModified",
+ table: "BaseItemImageInfos",
+ type: "TEXT",
+ nullable: true,
+ oldClrType: typeof(DateTime),
+ oldType: "TEXT");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "DateModified",
+ table: "BaseItemImageInfos",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
+ oldClrType: typeof(DateTime),
+ oldType: "TEXT",
+ oldNullable: true);
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.Designer.cs
new file mode 100644
index 000000000..3ceb907c1
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.Designer.cs
@@ -0,0 +1,1709 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20250714044826_ResetJournalMode")]
+ partial class ResetJournalMode
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Album")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property<float?>("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property<string>("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Data")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExtraIds")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property<float?>("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property<string>("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property<string>("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property<long?>("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property<long?>("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detacted from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<byte[]>("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ProviderValue", "ItemId");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("AverageFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("BitDepth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BlPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ChannelLayout")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("DvBlSignalCompatibilityId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvProfile")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMajor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMinor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("ElPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("Hdr10PlusPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAnamorphic")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAvc")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsExternal")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsHearingImpaired")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsInterlaced")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PixelFormat")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Profile")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("RealFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("RefFrames")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Rotation")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RpuPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SampleRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.HasIndex("StreamIndex");
+
+ b.HasIndex("StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType", "Language");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId");
+
+ b.HasIndex("PeopleId");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId");
+
+ b.HasIndex("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.ToTable("DeviceOptions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Bandwidth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Interval")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ThumbnailCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileHeight")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileWidth")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "Width");
+
+ b.ToTable("TrickplayInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CastReceiverId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFavorite")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastPlayedDate")
+ .HasColumnType("TEXT");
+
+ b.Property<bool?>("Likes")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("PlaybackPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property<double?>("Rating")
+ .HasColumnType("REAL");
+
+ b.Property<DateTime?>("RetentionDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Parents")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("Children")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("Parents");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences");
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs
new file mode 100644
index 000000000..23cb0c8ba
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250714044826_ResetJournalMode.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ /// <inheritdoc />
+ public partial class ResetJournalMode : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // Resets journal mode to WAL for users that have created their database during 10.11-RC1 or 2
+ migrationBuilder.Sql("PRAGMA journal_mode = 'WAL';", true);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.Designer.cs
new file mode 100644
index 000000000..5c5464a46
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.Designer.cs
@@ -0,0 +1,1721 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20250913211637_AddProperParentChildRelationBaseItemWithCascade")]
+ partial class AddProperParentChildRelationBaseItemWithCascade
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Album")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property<float?>("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property<string>("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Data")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExtraIds")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property<float?>("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property<string>("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property<string>("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property<long?>("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property<long?>("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detacted from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<byte[]>("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ProviderValue", "ItemId");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("AverageFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("BitDepth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BlPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ChannelLayout")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("DvBlSignalCompatibilityId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvProfile")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMajor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMinor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("ElPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("Hdr10PlusPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAnamorphic")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAvc")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsExternal")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsHearingImpaired")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsInterlaced")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PixelFormat")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Profile")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("RealFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("RefFrames")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Rotation")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RpuPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SampleRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.HasIndex("StreamIndex");
+
+ b.HasIndex("StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType", "Language");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId");
+
+ b.HasIndex("PeopleId");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId");
+
+ b.HasIndex("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.ToTable("DeviceOptions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Bandwidth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Interval")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ThumbnailCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileHeight")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileWidth")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "Width");
+
+ b.ToTable("TrickplayInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CastReceiverId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFavorite")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastPlayedDate")
+ .HasColumnType("TEXT");
+
+ b.Property<bool?>("Likes")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("PlaybackPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property<double?>("Rating")
+ .HasColumnType("REAL");
+
+ b.Property<DateTime?>("RetentionDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Parents")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("Children")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent")
+ .WithMany("DirectChildren")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("DirectParent");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("DirectChildren");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("Parents");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences");
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
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
new file mode 100644
index 000000000..38033d07f
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250913211637_AddProperParentChildRelationBaseItemWithCascade.cs
@@ -0,0 +1,52 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddProperParentChildRelationBaseItemWithCascade : Migration
+ {
+ /// <inheritdoc />
+ 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);
+""");
+ migrationBuilder.AddForeignKey(
+ name: "FK_BaseItems_BaseItems_ParentId",
+ table: "BaseItems",
+ column: "ParentId",
+ principalTable: "BaseItems",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_BaseItems_BaseItems_ParentId",
+ table: "BaseItems");
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.Designer.cs
new file mode 100644
index 000000000..edf30b0e7
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.Designer.cs
@@ -0,0 +1,1721 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20250925203415_ExtendPeopleMapKey")]
+ partial class ExtendPeopleMapKey
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Album")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property<float?>("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property<string>("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Data")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExtraIds")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property<float?>("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property<string>("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property<string>("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property<long?>("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property<long?>("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("Id", "Type", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detacted from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<byte[]>("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ProviderValue", "ItemId");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("AverageFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("BitDepth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BlPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ChannelLayout")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("DvBlSignalCompatibilityId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvProfile")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMajor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMinor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("ElPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("Hdr10PlusPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAnamorphic")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAvc")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsExternal")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsHearingImpaired")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsInterlaced")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PixelFormat")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Profile")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("RealFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("RefFrames")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Rotation")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RpuPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SampleRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.HasIndex("StreamIndex");
+
+ b.HasIndex("StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType");
+
+ b.HasIndex("StreamIndex", "StreamType", "Language");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId", "Role");
+
+ b.HasIndex("PeopleId");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId");
+
+ b.HasIndex("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.ToTable("DeviceOptions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Bandwidth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Interval")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ThumbnailCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileHeight")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileWidth")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "Width");
+
+ b.ToTable("TrickplayInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CastReceiverId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFavorite")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastPlayedDate")
+ .HasColumnType("TEXT");
+
+ b.Property<bool?>("Likes")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("PlaybackPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property<double?>("Rating")
+ .HasColumnType("REAL");
+
+ b.Property<DateTime?>("RetentionDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Parents")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("Children")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent")
+ .WithMany("DirectChildren")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("DirectParent");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("DirectChildren");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("Parents");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences");
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs
new file mode 100644
index 000000000..7c1bcdf44
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20250925203415_ExtendPeopleMapKey.cs
@@ -0,0 +1,54 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ /// <inheritdoc />
+ public partial class ExtendPeopleMapKey : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropPrimaryKey(
+ name: "PK_PeopleBaseItemMap",
+ table: "PeopleBaseItemMap");
+
+ migrationBuilder.AlterColumn<string>(
+ name: "Role",
+ table: "PeopleBaseItemMap",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: string.Empty,
+ oldClrType: typeof(string),
+ oldType: "TEXT",
+ oldNullable: true);
+
+ migrationBuilder.AddPrimaryKey(
+ name: "PK_PeopleBaseItemMap",
+ table: "PeopleBaseItemMap",
+ columns: new[] { "ItemId", "PeopleId", "Role" });
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropPrimaryKey(
+ name: "PK_PeopleBaseItemMap",
+ table: "PeopleBaseItemMap");
+
+ migrationBuilder.AlterColumn<string>(
+ name: "Role",
+ table: "PeopleBaseItemMap",
+ type: "TEXT",
+ nullable: true,
+ oldClrType: typeof(string),
+ oldType: "TEXT");
+
+ migrationBuilder.AddPrimaryKey(
+ name: "PK_PeopleBaseItemMap",
+ table: "PeopleBaseItemMap",
+ columns: new[] { "ItemId", "PeopleId" });
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
index dcdc5dd3e..bea2364d7 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
- modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@@ -392,6 +392,21 @@ namespace Jellyfin.Server.Implementations.Migrations
b.ToTable("BaseItems");
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detacted from its original item",
+ Type = "PLACEHOLDER"
+ });
});
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
@@ -403,7 +418,7 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<byte[]>("Blurhash")
.HasColumnType("BLOB");
- b.Property<DateTime>("DateModified")
+ b.Property<DateTime?>("DateModified")
.HasColumnType("TEXT");
b.Property<int>("Height")
@@ -984,16 +999,16 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<Guid>("PeopleId")
.HasColumnType("TEXT");
- b.Property<int?>("ListOrder")
- .HasColumnType("INTEGER");
-
b.Property<string>("Role")
.HasColumnType("TEXT");
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
b.Property<int?>("SortOrder")
.HasColumnType("INTEGER");
- b.HasKey("ItemId", "PeopleId");
+ b.HasKey("ItemId", "PeopleId", "Role");
b.HasIndex("PeopleId");
@@ -1373,6 +1388,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<double?>("Rating")
.HasColumnType("REAL");
+ b.Property<DateTime?>("RetentionDate")
+ .HasColumnType("TEXT");
+
b.Property<int?>("SubtitleStreamIndex")
.HasColumnType("INTEGER");
@@ -1432,6 +1450,16 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Navigation("Item");
});
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent")
+ .WithMany("DirectChildren")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("DirectParent");
+ });
+
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
{
b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
@@ -1634,6 +1662,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Navigation("Children");
+ b.Navigation("DirectChildren");
+
b.Navigation("Images");
b.Navigation("ItemValues");
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs
index 78815c311..4d420bf8c 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/SqliteDesignTimeJellyfinDbFactory.cs
@@ -1,4 +1,5 @@
using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Locking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions;
@@ -19,7 +20,8 @@ namespace Jellyfin.Database.Providers.Sqlite.Migrations
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
- new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance));
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs
new file mode 100644
index 000000000..fd2b9bd05
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/PragmaConnectionInterceptor.cs
@@ -0,0 +1,108 @@
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Globalization;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Database.Providers.Sqlite;
+
+/// <summary>
+/// Injects a series of PRAGMA on each connection starts.
+/// </summary>
+public class PragmaConnectionInterceptor : DbConnectionInterceptor
+{
+ private readonly ILogger _logger;
+ private readonly int? _cacheSize;
+ private readonly string _lockingMode;
+ private readonly int? _journalSizeLimit;
+ private readonly int _tempStoreMode;
+ private readonly int _syncMode;
+ private readonly IDictionary<string, string> _customPragma;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PragmaConnectionInterceptor"/> class.
+ /// </summary>
+ /// <param name="logger">The logger.</param>
+ /// <param name="cacheSize">Cache size.</param>
+ /// <param name="lockingMode">Locking mode.</param>
+ /// <param name="journalSizeLimit">Journal Size.</param>
+ /// <param name="tempStoreMode">The https://sqlite.org/pragma.html#pragma_temp_store pragma.</param>
+ /// <param name="syncMode">The https://sqlite.org/pragma.html#pragma_synchronous pragma.</param>
+ /// <param name="customPragma">A list of custom provided Pragma in the list of CustomOptions starting with "#PRAGMA:".</param>
+ public PragmaConnectionInterceptor(ILogger logger, int? cacheSize, string lockingMode, int? journalSizeLimit, int tempStoreMode, int syncMode, IDictionary<string, string> customPragma)
+ {
+ _logger = logger;
+ _cacheSize = cacheSize;
+ _lockingMode = lockingMode;
+ _journalSizeLimit = journalSizeLimit;
+ _tempStoreMode = tempStoreMode;
+ _syncMode = syncMode;
+ _customPragma = customPragma;
+
+ InitialCommand = BuildCommandText();
+ _logger.LogInformation("SQLITE connection pragma command set to: \r\n{PragmaCommand}", InitialCommand);
+ }
+
+ private string? InitialCommand { get; set; }
+
+ /// <inheritdoc/>
+ public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
+ {
+ base.ConnectionOpened(connection, eventData);
+
+ using (var command = connection.CreateCommand())
+ {
+#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
+ command.CommandText = InitialCommand;
+#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
+ command.ExecuteNonQuery();
+ }
+ }
+
+ /// <inheritdoc/>
+ public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
+ {
+ await base.ConnectionOpenedAsync(connection, eventData, cancellationToken).ConfigureAwait(false);
+
+ var command = connection.CreateCommand();
+ await using (command.ConfigureAwait(false))
+ {
+#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
+ command.CommandText = InitialCommand;
+#pragma warning restore CA2100 // Review SQL queries for security vulnerabilities
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private string BuildCommandText()
+ {
+ var sb = new StringBuilder();
+ if (_cacheSize.HasValue)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA cache_size={_cacheSize.Value};");
+ }
+
+ if (!string.IsNullOrWhiteSpace(_lockingMode))
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA locking_mode={_lockingMode};");
+ }
+
+ if (_journalSizeLimit.HasValue)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA journal_size_limit={_journalSizeLimit};");
+ }
+
+ sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA synchronous={_syncMode};");
+ sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA temp_store={_tempStoreMode};");
+
+ foreach (var item in _customPragma)
+ {
+ sb.AppendLine(CultureInfo.InvariantCulture, $"PRAGMA {item.Key}={item.Value};");
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
index 927ba63b9..da63df8e2 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs
@@ -1,9 +1,12 @@
using System;
+using System.Collections.Generic;
using System.Globalization;
using System.IO;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.DbConfiguration;
using MediaBrowser.Common.Configuration;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@@ -37,15 +40,59 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
/// <inheritdoc/>
- public void Initialise(DbContextOptionsBuilder options)
+ public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration)
{
+ static T? GetOption<T>(ICollection<CustomDatabaseOption>? options, string key, Func<string, T> converter, Func<T>? defaultValue = null)
+ {
+ if (options is null)
+ {
+ return defaultValue is not null ? defaultValue() : default;
+ }
+
+ var value = options.FirstOrDefault(e => e.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
+ if (value is null)
+ {
+ return defaultValue is not null ? defaultValue() : default;
+ }
+
+ return converter(value.Value);
+ }
+
+ var customOptions = databaseConfiguration.CustomProviderOptions?.Options;
+
+ var sqliteConnectionBuilder = new SqliteConnectionStringBuilder();
+ sqliteConnectionBuilder.DataSource = Path.Combine(_applicationPaths.DataPath, "jellyfin.db");
+ sqliteConnectionBuilder.Cache = GetOption(customOptions, "cache", Enum.Parse<SqliteCacheMode>, () => SqliteCacheMode.Default);
+ sqliteConnectionBuilder.Pooling = GetOption(customOptions, "pooling", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => true);
+ sqliteConnectionBuilder.DefaultTimeout = GetOption(customOptions, "command-timeout", int.Parse, () => 30);
+
+ var connectionString = sqliteConnectionBuilder.ToString();
+
+ // Log SQLite connection parameters
+ _logger.LogInformation("SQLite connection string: {ConnectionString}", connectionString);
+
options
.UseSqlite(
- $"Filename={Path.Combine(_applicationPaths.DataPath, "jellyfin.db")};Pooling=false",
+ connectionString,
sqLiteOptions => sqLiteOptions.MigrationsAssembly(GetType().Assembly))
// TODO: Remove when https://github.com/dotnet/efcore/pull/35873 is merged & released
.ConfigureWarnings(warnings =>
- warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning));
+ warnings.Ignore(RelationalEventId.NonTransactionalMigrationOperationWarning))
+ .AddInterceptors(new PragmaConnectionInterceptor(
+ _logger,
+ GetOption<int?>(customOptions, "cacheSize", e => int.Parse(e, CultureInfo.InvariantCulture)),
+ GetOption(customOptions, "lockingmode", e => e, () => "NORMAL")!,
+ GetOption(customOptions, "journalsizelimit", int.Parse, () => 134_217_728),
+ GetOption(customOptions, "tempstoremode", int.Parse, () => 2),
+ GetOption(customOptions, "syncmode", int.Parse, () => 1),
+ customOptions?.Where(e => e.Key.StartsWith("#PRAGMA:", StringComparison.OrdinalIgnoreCase)).ToDictionary(e => e.Key["#PRAGMA:".Length..], e => e.Value) ?? []));
+
+ var enableSensitiveDataLogging = GetOption(customOptions, "EnableSensitiveDataLogging", e => e.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase), () => false);
+ if (enableSensitiveDataLogging)
+ {
+ options.EnableSensitiveDataLogging(enableSensitiveDataLogging);
+ _logger.LogInformation("EnableSensitiveDataLogging is enabled on SQLite connection");
+ }
}
/// <inheritdoc/>
@@ -54,16 +101,11 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
- if (context.Database.IsSqlite())
- {
- await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
- await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
- _logger.LogInformation("jellyfin.db optimized successfully!");
- }
- else
- {
- _logger.LogInformation("This database doesn't support optimization");
- }
+ 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!");
}
}
@@ -76,8 +118,13 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
/// <inheritdoc/>
public async Task RunShutdownTask(CancellationToken cancellationToken)
{
+ if (DbContextFactory is null)
+ {
+ return;
+ }
+
// Run before disposing the application
- var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
+ var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
@@ -98,10 +145,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
var key = DateTime.UtcNow.ToString("yyyyMMddhhmmss", CultureInfo.InvariantCulture);
var path = Path.Combine(_applicationPaths.DataPath, "jellyfin.db");
var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName);
- if (!Directory.Exists(backupFile))
- {
- Directory.CreateDirectory(backupFile);
- }
+ Directory.CreateDirectory(backupFile);
backupFile = Path.Combine(backupFile, $"{key}_jellyfin.db");
File.Copy(path, backupFile);
@@ -118,11 +162,47 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
if (!File.Exists(backupFile))
{
- _logger.LogCritical("Tried to restore a backup that does not exist.");
+ _logger.LogCritical("Tried to restore a backup that does not exist: {Key}", key);
return Task.CompletedTask;
}
File.Copy(backupFile, path, true);
return Task.CompletedTask;
}
+
+ /// <inheritdoc />
+ public Task DeleteBackup(string key)
+ {
+ var backupFile = Path.Combine(_applicationPaths.DataPath, BackupFolderName, $"{key}_jellyfin.db");
+
+ if (!File.Exists(backupFile))
+ {
+ _logger.LogCritical("Tried to delete a backup that does not exist: {Key}", key);
+ return Task.CompletedTask;
+ }
+
+ File.Delete(backupFile);
+ return Task.CompletedTask;
+ }
+
+ /// <inheritdoc/>
+ public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
+ {
+ ArgumentNullException.ThrowIfNull(tableNames);
+
+ var deleteQueries = new List<string>();
+ foreach (var tableName in tableNames)
+ {
+ deleteQueries.Add($"DELETE FROM \"{tableName}\";");
+ }
+
+ var deleteAllQuery =
+ $"""
+ PRAGMA foreign_keys = OFF;
+ {string.Join('\n', deleteQueries)}
+ PRAGMA foreign_keys = ON;
+ """;
+
+ await dbContext.Database.ExecuteSqlRawAsync(deleteAllQuery).ConfigureAwait(false);
+ }
}
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index 73c8c3966..503e2f941 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -27,6 +27,16 @@ public class SkiaEncoder : IImageEncoder
private static readonly SKImageFilter _imageFilter;
private static readonly SKTypeface[] _typefaces;
+ /// <summary>
+ /// The default sampling options, equivalent to old high quality filter settings when upscaling.
+ /// </summary>
+ public static readonly SKSamplingOptions UpscaleSamplingOptions;
+
+ /// <summary>
+ /// The sampling options, used for downscaling images, equivalent to old high quality filter settings when not upscaling.
+ /// </summary>
+ public static readonly SKSamplingOptions DefaultSamplingOptions;
+
#pragma warning disable CA1810
static SkiaEncoder()
#pragma warning restore CA1810
@@ -63,6 +73,11 @@ public class SkiaEncoder : IImageEncoder
SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ي'), // Arabic
SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) // Default font
];
+
+ // use cubic for upscaling
+ UpscaleSamplingOptions = new SKSamplingOptions(SKCubicResampler.Mitchell);
+ // use bilinear for everything else
+ DefaultSamplingOptions = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
}
/// <summary>
@@ -187,18 +202,47 @@ public class SkiaEncoder : IImageEncoder
}
}
- using var codec = SKCodec.Create(path, out SKCodecResult result);
+ var safePath = NormalizePath(path);
+ if (new FileInfo(safePath).Length == 0)
+ {
+ _logger.LogDebug("Skip zero‑byte image {FilePath}", path);
+ return default;
+ }
+
+ using var codec = SKCodec.Create(safePath, out var result);
+
switch (result)
{
case SKCodecResult.Success:
+ // Skia/SkiaSharp edge‑case: when the image header is parsed but the actual pixel
+ // decode fails (truncated JPEG/PNG, exotic ICC/EXIF, CMYK without color‑transform, etc.)
+ // `SKCodec.Create` returns a *non‑null* codec together with
+ // SKCodecResult.InternalError. The header still contains valid dimensions,
+ // which is all we need here – so we fall back to them instead of aborting.
+ // See e.g. Skia bugs #4139, #6092.
+ case SKCodecResult.InternalError when codec is not null:
var info = codec.Info;
return new ImageDimensions(info.Width, info.Height);
+
case SKCodecResult.Unimplemented:
_logger.LogDebug("Image format not supported: {FilePath}", path);
return default;
+
default:
- _logger.LogError("Unable to determine image dimensions for {FilePath}: {SkCodecResult}", path, result);
+ {
+ var boundsInfo = SKBitmap.DecodeBounds(safePath);
+
+ if (boundsInfo.Width > 0 && boundsInfo.Height > 0)
+ {
+ return new ImageDimensions(boundsInfo.Width, boundsInfo.Height);
+ }
+
+ _logger.LogWarning(
+ "Unable to determine image dimensions for {FilePath}: {SkCodecResult}",
+ path,
+ result);
return default;
+ }
}
}
@@ -441,7 +485,7 @@ public class SkiaEncoder : IImageEncoder
break;
}
- surface.DrawBitmap(bitmap, 0, 0);
+ surface.DrawBitmap(bitmap, 0, 0, DefaultSamplingOptions);
return rotated;
}
catch (Exception e)
@@ -467,18 +511,23 @@ public class SkiaEncoder : IImageEncoder
{
using var surface = SKSurface.Create(targetInfo);
using var canvas = surface.Canvas;
- using var paint = new SKPaint
- {
- FilterQuality = SKFilterQuality.High,
- IsAntialias = isAntialias,
- IsDither = isDither
- };
+ using var paint = new SKPaint();
+ paint.IsAntialias = isAntialias;
+ paint.IsDither = isDither;
+
+ // 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
+ ? DefaultSamplingOptions
+ : UpscaleSamplingOptions;
paint.ImageFilter = _imageFilter;
canvas.DrawBitmap(
source,
SKRect.Create(0, 0, source.Width, source.Height),
SKRect.Create(0, 0, targetInfo.Width, targetInfo.Height),
+ samplingOptions,
paint);
return surface.Snapshot();
@@ -560,11 +609,10 @@ public class SkiaEncoder : IImageEncoder
using var paint = new SKPaint();
// Add blur if option is present
using var filter = blur > 0 ? SKImageFilter.CreateBlur(blur, blur) : null;
- paint.FilterQuality = SKFilterQuality.High;
paint.ImageFilter = filter;
// create image from resized bitmap to apply blur
- canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), paint);
+ canvas.DrawBitmap(resizedBitmap, SKRect.Create(width, height), DefaultSamplingOptions, paint);
// If foreground layer present then draw
if (hasForegroundColor)
@@ -690,7 +738,7 @@ public class SkiaEncoder : IImageEncoder
throw new InvalidOperationException("Image height does not match first image height.");
}
- canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value);
+ canvas.DrawBitmap(img, x * imgWidth, y * imgHeight.Value, DefaultSamplingOptions);
}
}
diff --git a/src/Jellyfin.Drawing.Skia/SkiaExtensions.cs b/src/Jellyfin.Drawing.Skia/SkiaExtensions.cs
new file mode 100644
index 000000000..f7d6842ff
--- /dev/null
+++ b/src/Jellyfin.Drawing.Skia/SkiaExtensions.cs
@@ -0,0 +1,58 @@
+using SkiaSharp;
+
+namespace Jellyfin.Drawing.Skia;
+
+/// <summary>
+/// The SkiaSharp extensions.
+/// </summary>
+public static class SkiaExtensions
+{
+ /// <summary>
+ /// Draws an SKBitmap on the canvas with specified SkSamplingOptions.
+ /// </summary>
+ /// <param name="canvas">The SKCanvas to draw on.</param>
+ /// <param name="bitmap">The SKBitmap to draw.</param>
+ /// <param name="dest">The destination SKRect.</param>
+ /// <param name="options">The SKSamplingOptions to use for rendering.</param>
+ /// <param name="paint">Optional SKPaint to apply additional effects or styles.</param>
+ public static void DrawBitmap(this SKCanvas canvas, SKBitmap bitmap, SKRect dest, SKSamplingOptions options, SKPaint? paint = null)
+ {
+ using var image = SKImage.FromBitmap(bitmap);
+ canvas.DrawImage(image, dest, options, paint);
+ }
+
+ /// <summary>
+ /// Draws an SKBitmap on the canvas at the specified coordinates with the given SkSamplingOptions.
+ /// </summary>
+ /// <param name="canvas">The SKCanvas to draw on.</param>
+ /// <param name="bitmap">The SKBitmap to draw.</param>
+ /// <param name="x">The x-coordinate where the bitmap will be drawn.</param>
+ /// <param name="y">The y-coordinate where the bitmap will be drawn.</param>
+ /// <param name="options">The SKSamplingOptions to use for rendering.</param>
+ /// <param name="paint">Optional SKPaint to apply additional effects or styles.</param>
+ public static void DrawBitmap(this SKCanvas canvas, SKBitmap bitmap, float x, float y, SKSamplingOptions options, SKPaint? paint = null)
+ {
+ using var image = SKImage.FromBitmap(bitmap);
+ canvas.DrawImage(image, x, y, options, paint);
+ }
+
+ /// <summary>
+ /// Draws an SKBitmap on the canvas using a specified source rectangle, destination rectangle,
+ /// and optional paint, with the given SkSamplingOptions.
+ /// </summary>
+ /// <param name="canvas">The SKCanvas to draw on.</param>
+ /// <param name="bitmap">The SKBitmap to draw.</param>
+ /// <param name="source">
+ /// The source SKRect defining the portion of the bitmap to draw.
+ /// </param>
+ /// <param name="dest">
+ /// The destination SKRect defining the area on the canvas where the bitmap will be drawn.
+ /// </param>
+ /// <param name="options">The SKSamplingOptions to use for rendering.</param>
+ /// <param name="paint">Optional SKPaint to apply additional effects or styles.</param>
+ public static void DrawBitmap(this SKCanvas canvas, SKBitmap bitmap, SKRect source, SKRect dest, SKSamplingOptions options, SKPaint? paint = null)
+ {
+ using var image = SKImage.FromBitmap(bitmap);
+ canvas.DrawImage(image, source, dest, options, paint);
+ }
+}
diff --git a/src/Jellyfin.Drawing.Skia/SkiaHelper.cs b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs
index bd1b2b0da..87446236c 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaHelper.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.IO;
using SkiaSharp;
namespace Jellyfin.Drawing.Skia;
@@ -27,12 +28,17 @@ public static class SkiaHelper
currentIndex = 0;
}
- SKBitmap? bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _);
-
+ var imagePath = paths[currentIndex];
imagesTested[currentIndex] = 0;
-
currentIndex++;
+ if (!Path.Exists(imagePath))
+ {
+ continue;
+ }
+
+ SKBitmap? bitmap = skiaEncoder.Decode(imagePath, false, null, out _);
+
if (bitmap is not null)
{
newIndex = currentIndex;
diff --git a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs
index 03733d4f8..554707a3f 100644
--- a/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs
+++ b/src/Jellyfin.Drawing.Skia/SplashscreenBuilder.cs
@@ -101,10 +101,12 @@ public class SplashscreenBuilder
{
var imageWidth = Math.Abs(posterHeight * currentImage.Width / currentImage.Height);
using var resizedBitmap = new SKBitmap(imageWidth, posterHeight);
- currentImage.ScalePixels(resizedBitmap, SKFilterQuality.High);
-
+ var samplingOptions = currentImage.Width > imageWidth || currentImage.Height > posterHeight
+ ? SkiaEncoder.DefaultSamplingOptions
+ : SkiaEncoder.UpscaleSamplingOptions;
+ currentImage.ScalePixels(resizedBitmap, samplingOptions);
// draw on canvas
- canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight);
+ canvas.DrawBitmap(resizedBitmap, currentWidthPos, currentHeight, samplingOptions);
// resize to the same aspect as the original
currentWidthPos += imageWidth + Spacing;
diff --git a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
index 03e202e5a..64c33d5c2 100644
--- a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
+++ b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
@@ -111,38 +111,31 @@ public partial class StripCollageBuilder
var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width);
using var resizedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace));
using var paint = new SKPaint();
- paint.FilterQuality = SKFilterQuality.High;
// draw the backdrop
- canvas.DrawImage(resizedBackdrop, 0, 0, paint);
+ canvas.DrawImage(resizedBackdrop, 0, 0, SkiaEncoder.DefaultSamplingOptions, paint);
// draw shadow rectangle
- using var paintColor = new SKPaint
- {
- Color = SKColors.Black.WithAlpha(0x78),
- Style = SKPaintStyle.Fill,
- FilterQuality = SKFilterQuality.High
- };
+ using var paintColor = new SKPaint();
+ paintColor.Color = SKColors.Black.WithAlpha(0x78);
+ paintColor.Style = SKPaintStyle.Fill;
canvas.DrawRect(0, 0, width, height, paintColor);
var typeFace = SkiaEncoder.DefaultTypeFace;
// draw library name
- using var textPaint = new SKPaint
- {
- Color = SKColors.White,
- Style = SKPaintStyle.Fill,
- TextSize = 112,
- TextAlign = SKTextAlign.Left,
- Typeface = typeFace,
- IsAntialias = true,
- FilterQuality = SKFilterQuality.High
- };
+ using var textFont = new SKFont();
+ textFont.Size = 112;
+ textFont.Typeface = typeFace;
+ using var textPaint = new SKPaint();
+ textPaint.Color = SKColors.White;
+ textPaint.Style = SKPaintStyle.Fill;
+ textPaint.IsAntialias = true;
// scale down text to 90% of the width if text is larger than 95% of the width
- var textWidth = textPaint.MeasureText(libraryName);
+ var textWidth = textFont.MeasureText(libraryName);
if (textWidth > width * 0.95)
{
- textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth;
+ textFont.Size = 0.9f * width * textFont.Size / textWidth;
}
if (string.IsNullOrWhiteSpace(libraryName))
@@ -150,23 +143,22 @@ public partial class StripCollageBuilder
return bitmap;
}
- var realWidth = DrawText(null, 0, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint);
+ var realWidth = DrawText(null, 0, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFont);
if (realWidth > width * 0.95)
{
- textPaint.TextSize = 0.9f * width * textPaint.TextSize / realWidth;
- realWidth = DrawText(null, 0, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint);
+ textFont.Size = 0.9f * width * textFont.Size / realWidth;
+ realWidth = DrawText(null, 0, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFont);
}
var padding = (width - realWidth) / 2;
if (IsRtlTextRegex().IsMatch(libraryName))
{
- textPaint.TextAlign = SKTextAlign.Right;
- DrawText(canvas, width - padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint, true);
+ DrawText(canvas, width - padding, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFont, true);
}
else
{
- DrawText(canvas, padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint);
+ DrawText(canvas, padding, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFont);
}
return bitmap;
@@ -196,12 +188,11 @@ public partial class StripCollageBuilder
var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace);
using var resizeImage = SkiaEncoder.ResizeImage(currentBitmap, imageInfo);
using var paint = new SKPaint();
- paint.FilterQuality = SKFilterQuality.High;
// draw this image into the strip at the next position
var xPos = x * cellWidth;
var yPos = y * cellHeight;
- canvas.DrawImage(resizeImage, xPos, yPos, paint);
+ canvas.DrawImage(resizeImage, xPos, yPos, SkiaEncoder.DefaultSamplingOptions, paint);
}
}
@@ -216,11 +207,13 @@ public partial class StripCollageBuilder
/// <param name="y">y position of the canvas to draw text.</param>
/// <param name="text">The text to draw.</param>
/// <param name="textPaint">The SKPaint to style the text.</param>
+ /// <param name="textFont">The SKFont to style the text.</param>
+ /// <param name="alignment">The alignment of the text. Default aligns to left.</param>
/// <returns>The width of the text.</returns>
- private static float MeasureAndDrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint)
+ private static float MeasureAndDrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, SKFont textFont, SKTextAlign alignment = SKTextAlign.Left)
{
- var width = textPaint.MeasureText(text);
- canvas?.DrawShapedText(text, x, y, textPaint);
+ var width = textFont.MeasureText(text);
+ canvas?.DrawShapedText(text, x, y, alignment, textFont, textPaint);
return width;
}
@@ -232,16 +225,18 @@ public partial class StripCollageBuilder
/// <param name="y">y position of the canvas to draw text.</param>
/// <param name="text">The text to draw.</param>
/// <param name="textPaint">The SKPaint to style the text.</param>
+ /// <param name="textFont">The SKFont to style the text.</param>
/// <param name="isRtl">If true, render from right to left.</param>
/// <returns>The width of the text.</returns>
- private static float DrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, bool isRtl = false)
+ private static float DrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, SKFont textFont, bool isRtl = false)
{
float width = 0;
+ var alignment = isRtl ? SKTextAlign.Right : SKTextAlign.Left;
- if (textPaint.ContainsGlyphs(text))
+ if (textFont.ContainsGlyphs(text))
{
// Current font can render all characters in text
- return MeasureAndDrawText(canvas, x, y, text, textPaint);
+ return MeasureAndDrawText(canvas, x, y, text, textPaint, textFont, alignment);
}
// Iterate over all text elements using TextElementEnumerator
@@ -254,7 +249,7 @@ public partial class StripCollageBuilder
{
bool notAtEnd;
var textElement = enumerator.GetTextElement();
- if (textPaint.ContainsGlyphs(textElement))
+ if (textFont.ContainsGlyphs(textElement))
{
continue;
}
@@ -264,12 +259,12 @@ public partial class StripCollageBuilder
if (start != enumerator.ElementIndex)
{
var regularText = text.Substring(start, enumerator.ElementIndex - start);
- width += MeasureAndDrawText(canvas, MoveX(x, width), y, regularText, textPaint);
+ width += MeasureAndDrawText(canvas, MoveX(x, width), y, regularText, textPaint, textFont, alignment);
start = enumerator.ElementIndex;
}
// Search for next point where current font can render the character there
- while ((notAtEnd = enumerator.MoveNext()) && !textPaint.ContainsGlyphs(enumerator.GetTextElement()))
+ while ((notAtEnd = enumerator.MoveNext()) && !textFont.ContainsGlyphs(enumerator.GetTextElement()))
{
// Do nothing, just move enumerator to the point where current font can render the character
}
@@ -284,21 +279,21 @@ public partial class StripCollageBuilder
if (fallback is not null)
{
+ using var fallbackTextFont = new SKFont();
+ fallbackTextFont.Size = textFont.Size;
+ fallbackTextFont.Typeface = fallback;
using var fallbackTextPaint = new SKPaint();
fallbackTextPaint.Color = textPaint.Color;
fallbackTextPaint.Style = textPaint.Style;
- fallbackTextPaint.TextSize = textPaint.TextSize;
- fallbackTextPaint.TextAlign = textPaint.TextAlign;
- fallbackTextPaint.Typeface = fallback;
fallbackTextPaint.IsAntialias = textPaint.IsAntialias;
// Do the search recursively to select all possible fonts
- width += DrawText(canvas, MoveX(x, width), y, subtext, fallbackTextPaint, isRtl);
+ width += DrawText(canvas, MoveX(x, width), y, subtext, fallbackTextPaint, fallbackTextFont, isRtl);
}
else
{
// Used up all fonts and no fonts can be found, just use current font
- width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint);
+ width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint, textFont, alignment);
}
start = notAtEnd ? enumerator.ElementIndex : text.Length;
@@ -307,7 +302,7 @@ public partial class StripCollageBuilder
// Render the remaining text that current fonts can render
if (start < text.Length)
{
- width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint);
+ width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint, textFont, alignment);
}
return width;
diff --git a/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs b/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs
index 456b84b8c..46c48357e 100644
--- a/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs
+++ b/src/Jellyfin.Drawing.Skia/UnplayedCountIndicator.cs
@@ -34,10 +34,12 @@ public static class UnplayedCountIndicator
Style = SKPaintStyle.Fill
};
+ using var font = new SKFont();
+
canvas.DrawCircle(x, OffsetFromTopRightCorner, 20, paint);
paint.Color = new SKColor(255, 255, 255, 255);
- paint.TextSize = 24;
+ font.Size = 24;
paint.IsAntialias = true;
var y = OffsetFromTopRightCorner + 9;
@@ -55,9 +57,9 @@ public static class UnplayedCountIndicator
{
x -= 15;
y -= 2;
- paint.TextSize = 18;
+ font.Size = 18;
}
- canvas.DrawText(text, x, y, paint);
+ canvas.DrawText(text, x, y, font, paint);
}
}
diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs
index 7718f6c6a..46e5213a8 100644
--- a/src/Jellyfin.Drawing/ImageProcessor.cs
+++ b/src/Jellyfin.Drawing/ImageProcessor.cs
@@ -34,7 +34,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
private const char Version = '3';
private static readonly HashSet<string> _transparentImageTypes
- = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif" };
+ = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".png", ".webp", ".gif", ".svg" };
private readonly ILogger<ImageProcessor> _logger;
private readonly IFileSystem _fileSystem;
@@ -524,11 +524,11 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable
/// <inheritdoc />
public void CreateImageCollage(ImageCollageOptions options, string? libraryName)
{
- _logger.LogInformation("Creating image collage and saving to {Path}", options.OutputPath);
+ _logger.LogDebug("Creating image collage and saving to {Path}", options.OutputPath);
_imageEncoder.CreateImageCollage(options, libraryName);
- _logger.LogInformation("Completed creation of image collage and saved to {Path}", options.OutputPath);
+ _logger.LogDebug("Completed creation of image collage and saved to {Path}", options.OutputPath);
}
/// <inheritdoc />
diff --git a/src/Jellyfin.Extensions/DictionaryExtensions.cs b/src/Jellyfin.Extensions/DictionaryExtensions.cs
index 5bb828d01..814297093 100644
--- a/src/Jellyfin.Extensions/DictionaryExtensions.cs
+++ b/src/Jellyfin.Extensions/DictionaryExtensions.cs
@@ -13,35 +13,11 @@ namespace Jellyfin.Extensions
/// </summary>
/// <param name="dictionary">The dictionary.</param>
/// <param name="key1">The first checked key.</param>
- /// <returns>System.String.</returns>
- public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1)
- {
- return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, string.Empty, string.Empty);
- }
-
- /// <summary>
- /// Gets a string from a string dictionary, checking all keys sequentially,
- /// stopping at the first key that returns a result that's neither null nor blank.
- /// </summary>
- /// <param name="dictionary">The dictionary.</param>
- /// <param name="key1">The first checked key.</param>
- /// <param name="key2">The second checked key.</param>
- /// <returns>System.String.</returns>
- public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string key2)
- {
- return dictionary.GetFirstNotNullNorWhiteSpaceValue(key1, key2, string.Empty);
- }
-
- /// <summary>
- /// Gets a string from a string dictionary, checking all keys sequentially,
- /// stopping at the first key that returns a result that's neither null nor blank.
- /// </summary>
- /// <param name="dictionary">The dictionary.</param>
- /// <param name="key1">The first checked key.</param>
/// <param name="key2">The second checked key.</param>
/// <param name="key3">The third checked key.</param>
+ /// <param name="key4">The fourth checked key.</param>
/// <returns>System.String.</returns>
- public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string key2, string key3)
+ public static string? GetFirstNotNullNorWhiteSpaceValue(this IReadOnlyDictionary<string, string> dictionary, string key1, string? key2 = null, string? key3 = null, string? key4 = null)
{
if (dictionary.TryGetValue(key1, out var val) && !string.IsNullOrWhiteSpace(val))
{
@@ -58,6 +34,11 @@ namespace Jellyfin.Extensions
return val;
}
+ if (!string.IsNullOrEmpty(key4) && dictionary.TryGetValue(key4, out val) && !string.IsNullOrWhiteSpace(val))
+ {
+ return val;
+ }
+
return null;
}
}
diff --git a/src/Jellyfin.Extensions/FileHelper.cs b/src/Jellyfin.Extensions/FileHelper.cs
new file mode 100644
index 000000000..b1ccf8d47
--- /dev/null
+++ b/src/Jellyfin.Extensions/FileHelper.cs
@@ -0,0 +1,20 @@
+using System.IO;
+
+namespace Jellyfin.Extensions;
+
+/// <summary>
+/// Provides helper functions for <see cref="File" />.
+/// </summary>
+public static class FileHelper
+{
+ /// <summary>
+ /// Creates, or truncates a file in the specified path.
+ /// </summary>
+ /// <param name="path">The path and name of the file to create.</param>
+ public static void CreateEmpty(string path)
+ {
+ using (File.OpenHandle(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None))
+ {
+ }
+ }
+}
diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
index 1613d83bc..f52fd014d 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>10.11.0</VersionPrefix>
+ <VersionPrefix>10.12.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs
index 715cbf220..60df47113 100644
--- a/src/Jellyfin.Extensions/StringExtensions.cs
+++ b/src/Jellyfin.Extensions/StringExtensions.cs
@@ -135,5 +135,18 @@ namespace Jellyfin.Extensions
{
return values.Select(i => (i ?? string.Empty).Trim());
}
+
+ /// <summary>
+ /// Truncates a string at the first null character ('\0').
+ /// </summary>
+ /// <param name="text">The input string.</param>
+ /// <returns>
+ /// The substring up to (but not including) the first null character,
+ /// or the original string if no null character is present.
+ /// </returns>
+ public static string TruncateAtNull(this string text)
+ {
+ return string.IsNullOrEmpty(text) ? text : text.AsSpan().LeftPart('\0').ToString();
+ }
}
}
diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
index 0ca294a28..8ee129a57 100644
--- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
+++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
@@ -363,7 +363,7 @@ namespace Jellyfin.LiveTv.Channels
Directory.CreateDirectory(Path.GetDirectoryName(path));
- FileStream createStream = File.Create(path);
+ FileStream createStream = AsyncFile.Create(path);
await using (createStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false);
@@ -445,12 +445,13 @@ namespace Jellyfin.LiveTv.Channels
if (item is null)
{
+ var info = Directory.CreateDirectory(path);
item = new Channel
{
Name = channelInfo.Name,
Id = id,
- DateCreated = _fileSystem.GetCreationTimeUtc(path),
- DateModified = _fileSystem.GetLastWriteTimeUtc(path)
+ DateCreated = info.CreationTimeUtc,
+ DateModified = info.LastWriteTimeUtc
};
isNew = true;
@@ -866,7 +867,7 @@ namespace Jellyfin.LiveTv.Channels
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
- var createStream = File.Create(path);
+ var createStream = AsyncFile.Create(path);
await using (createStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(createStream, result, _jsonOptions).ConfigureAwait(false);
@@ -1165,7 +1166,7 @@ namespace Jellyfin.LiveTv.Channels
}
}
- if (isNew || forceUpdate || item.DateLastRefreshed == default)
+ if (isNew || forceUpdate || item.DateLastRefreshed == DateTime.MinValue)
{
_providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.Normal);
}
diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
index c04954207..be7ff5297 100644
--- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
+++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs
@@ -73,6 +73,10 @@ namespace Jellyfin.LiveTv.IO
{
_targetPath = targetFile;
Directory.CreateDirectory(Path.GetDirectoryName(targetFile));
+ if (!File.Exists(targetFile))
+ {
+ FileHelper.CreateEmpty(targetFile);
+ }
var processStartInfo = new ProcessStartInfo
{
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index 40adb51a5..53bc6751f 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -125,8 +125,8 @@ namespace Jellyfin.LiveTv
IsKids = query.IsKids,
IsSports = query.IsSports,
IsSeries = query.IsSeries,
- IncludeItemTypes = new[] { BaseItemKind.LiveTvChannel },
- TopParentIds = new[] { topFolder.Id },
+ IncludeItemTypes = [BaseItemKind.LiveTvChannel],
+ TopParentIds = [topFolder.Id],
IsFavorite = query.IsFavorite,
IsLiked = query.IsLiked,
StartIndex = query.StartIndex,
@@ -199,17 +199,17 @@ namespace Jellyfin.LiveTv
if (query.OrderBy.Count == 0)
{
// Unless something else was specified, order by start date to take advantage of a specialized index
- query.OrderBy = new[]
- {
+ query.OrderBy =
+ [
(ItemSortBy.StartDate, SortOrder.Ascending)
- };
+ ];
}
RemoveFields(options);
var internalQuery = new InternalItemsQuery(user)
{
- IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
+ IncludeItemTypes = [BaseItemKind.LiveTvProgram],
MinEndDate = query.MinEndDate,
MinStartDate = query.MinStartDate,
MaxEndDate = query.MaxEndDate,
@@ -226,7 +226,7 @@ namespace Jellyfin.LiveTv
Limit = query.Limit,
OrderBy = query.OrderBy,
EnableTotalRecordCount = query.EnableTotalRecordCount,
- TopParentIds = new[] { topFolder.Id },
+ TopParentIds = [topFolder.Id],
Name = query.Name,
DtoOptions = options,
HasAired = query.HasAired,
@@ -272,7 +272,7 @@ namespace Jellyfin.LiveTv
var internalQuery = new InternalItemsQuery(user)
{
- IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
+ IncludeItemTypes = [BaseItemKind.LiveTvProgram],
IsAiring = query.IsAiring,
HasAired = query.HasAired,
IsNews = query.IsNews,
@@ -281,8 +281,8 @@ namespace Jellyfin.LiveTv
IsSports = query.IsSports,
IsKids = query.IsKids,
EnableTotalRecordCount = query.EnableTotalRecordCount,
- OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) },
- TopParentIds = new[] { topFolder.Id },
+ OrderBy = [(ItemSortBy.StartDate, SortOrder.Ascending)],
+ TopParentIds = [topFolder.Id],
DtoOptions = options,
GenreIds = query.GenreIds
};
@@ -497,19 +497,19 @@ namespace Jellyfin.LiveTv
// TotalRecordCount = items.Length
// };
- dtoOptions.Fields = dtoOptions.Fields.Concat(new[] { ItemFields.Tags }).Distinct().ToArray();
+ dtoOptions.Fields = dtoOptions.Fields.Concat([ItemFields.Tags]).Distinct().ToArray();
}
var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
{
- MediaTypes = new[] { MediaType.Video },
+ MediaTypes = [MediaType.Video],
Recursive = true,
AncestorIds = folderIds,
IsFolder = false,
IsVirtualItem = false,
Limit = limit,
StartIndex = query.StartIndex,
- OrderBy = new[] { (ItemSortBy.DateCreated, SortOrder.Descending) },
+ OrderBy = [(ItemSortBy.DateCreated, SortOrder.Descending)],
EnableTotalRecordCount = query.EnableTotalRecordCount,
IncludeItemTypes = includeItemTypes.ToArray(),
ExcludeItemTypes = excludeItemTypes.ToArray(),
@@ -959,13 +959,13 @@ namespace Jellyfin.LiveTv
var programs = options.AddCurrentProgram ? _libraryManager.GetItemList(new InternalItemsQuery(user)
{
- IncludeItemTypes = new[] { BaseItemKind.LiveTvProgram },
+ IncludeItemTypes = [BaseItemKind.LiveTvProgram],
ChannelIds = channelIds,
MaxStartDate = now,
MinEndDate = now,
Limit = channelIds.Length,
- OrderBy = new[] { (ItemSortBy.StartDate, SortOrder.Ascending) },
- TopParentIds = new[] { GetInternalLiveTvFolder(CancellationToken.None).Id },
+ OrderBy = [(ItemSortBy.StartDate, SortOrder.Ascending)],
+ TopParentIds = [GetInternalLiveTvFolder(CancellationToken.None).Id],
DtoOptions = options
}) : new List<BaseItem>();
@@ -1269,7 +1269,7 @@ namespace Jellyfin.LiveTv
{
var folders = _recordingsManager.GetRecordingFolders()
.SelectMany(i => i.Locations)
- .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Distinct()
.Select(i => _libraryManager.FindByPath(i, true))
.Where(i => i is not null && i.IsVisibleStandalone(user))
.SelectMany(i => _libraryManager.GetCollectionFolders(i))
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
index 9ca5d7420..846f9baf7 100644
--- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
@@ -230,7 +230,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
if (pathsAdded.Count > 0 || pathsToRemove.Count > 0)
{
pathsAdded.InsertRange(0, config.MediaLocationsCreated);
- config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ config.MediaLocationsCreated = pathsAdded.Except(pathsToRemove).Distinct().ToArray();
_config.SaveConfiguration("livetv", config);
}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs
index e3afe1513..227075845 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs
@@ -200,8 +200,7 @@ namespace Jellyfin.LiveTv.TunerHosts
var numberIndex = nameInExtInf.IndexOf(' ');
if (numberIndex > 0)
{
- var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' });
-
+ var numberPart = nameInExtInf[..numberIndex].Trim(stackalloc[] { ' ', '.' });
if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
{
numberString = numberPart.ToString();
@@ -273,12 +272,12 @@ namespace Jellyfin.LiveTv.TunerHosts
var numberIndex = nameInExtInf.IndexOf(' ', StringComparison.Ordinal);
if (numberIndex > 0)
{
- var numberPart = nameInExtInf.AsSpan(0, numberIndex).Trim(new[] { ' ', '.' });
+ var numberPart = nameInExtInf.AsSpan(0, numberIndex).Trim(stackalloc[] { ' ', '.' });
if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
{
// channel.Number = number.ToString();
- nameInExtInf = nameInExtInf.AsSpan(numberIndex + 1).Trim(new[] { ' ', '-' }).ToString();
+ nameInExtInf = nameInExtInf.AsSpan(numberIndex + 1).Trim(stackalloc[] { ' ', '-' }).ToString();
}
}
}
diff --git a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
index d63ee6777..fcf37f35d 100644
--- a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
+++ b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
@@ -75,6 +75,8 @@ public class KeyframeExtractionScheduledTask : IScheduledTask
var videos = _libraryManager.GetItemList(query);
foreach (var video in videos)
{
+ cancellationToken.ThrowIfCancellationRequested();
+
// Only local files supported
var path = video.Path;
if (File.Exists(path))
diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
index 570c0b9d0..cbe97a821 100644
--- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
+++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs
@@ -42,6 +42,15 @@ public static class FfProbeKeyframeExtractor
try
{
process.Start();
+ try
+ {
+ process.PriorityClass = ProcessPriorityClass.BelowNormal;
+ }
+ catch
+ {
+ // We do not care if process priority setting fails
+ // Ideally log a warning but this does not have a logger available
+ }
return ParseStream(process.StandardOutput);
}
diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj
index c826d3d9c..cc8d942eb 100644
--- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj
+++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj
@@ -5,6 +5,19 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
+ <PropertyGroup>
+ <Authors>Jellyfin Contributors</Authors>
+ <PackageId>Jellyfin.MediaEncoding.Keyframes</PackageId>
+ <VersionPrefix>10.11.0</VersionPrefix>
+ <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
+ <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
+ </PropertyGroup>
+
+ <PropertyGroup Condition=" '$(Stability)'=='Unstable'">
+ <!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. -->
+ <AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
+ </PropertyGroup>
+
<ItemGroup>
<PackageReference Include="NEbml" />
</ItemGroup>
diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs
index 80a5741df..126d9f15c 100644
--- a/src/Jellyfin.Networking/Manager/NetworkManager.cs
+++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs
@@ -690,33 +690,42 @@ public class NetworkManager : INetworkManager, IDisposable
}
/// <inheritdoc/>
- public bool HasRemoteAccess(IPAddress remoteIP)
+ public RemoteAccessPolicyResult ShouldAllowServerAccess(IPAddress remoteIP)
{
var config = _configurationManager.GetNetworkConfiguration();
- if (config.EnableRemoteAccess)
+ if (IsInLocalNetwork(remoteIP))
{
- // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
- // If left blank, all remote addresses will be allowed.
- if (_remoteAddressFilter.Any() && !IsInLocalNetwork(remoteIP))
- {
- // remoteAddressFilter is a whitelist or blacklist.
- var matches = _remoteAddressFilter.Count(remoteNetwork => NetworkUtils.SubnetContainsAddress(remoteNetwork, remoteIP));
- if ((!config.IsRemoteIPFilterBlacklist && matches > 0)
- || (config.IsRemoteIPFilterBlacklist && matches == 0))
- {
- return true;
- }
-
- return false;
- }
+ return RemoteAccessPolicyResult.Allow;
}
- else if (!IsInLocalNetwork(remoteIP))
+
+ if (!config.EnableRemoteAccess)
{
// Remote not enabled. So everyone should be LAN.
- return false;
+ return RemoteAccessPolicyResult.RejectDueToRemoteAccessDisabled;
}
- return true;
+ if (!_remoteAddressFilter.Any())
+ {
+ // No filter on remote addresses, allow any of them.
+ return RemoteAccessPolicyResult.Allow;
+ }
+
+ // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
+ // If left blank, all remote addresses will be allowed.
+
+ // remoteAddressFilter is a whitelist or blacklist.
+ var anyMatches = _remoteAddressFilter.Any(remoteNetwork => NetworkUtils.SubnetContainsAddress(remoteNetwork, remoteIP));
+ if (config.IsRemoteIPFilterBlacklist)
+ {
+ return anyMatches
+ ? RemoteAccessPolicyResult.RejectDueToIPBlocklist
+ : RemoteAccessPolicyResult.Allow;
+ }
+
+ // Allow-list
+ return anyMatches
+ ? RemoteAccessPolicyResult.Allow
+ : RemoteAccessPolicyResult.RejectDueToNotAllowlistedRemoteIP;
}
/// <inheritdoc/>
diff --git a/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs
index dd84c1a18..8cb3cde2b 100644
--- a/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs
@@ -1,4 +1,5 @@
using Jellyfin.Api.Controllers;
+using Jellyfin.Server.Implementations.SystemBackupService;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Model.IO;
diff --git a/tests/Jellyfin.Extensions.Tests/FileHelperTests.cs b/tests/Jellyfin.Extensions.Tests/FileHelperTests.cs
new file mode 100644
index 000000000..fb6a5dd0a
--- /dev/null
+++ b/tests/Jellyfin.Extensions.Tests/FileHelperTests.cs
@@ -0,0 +1,23 @@
+using System.IO;
+using Xunit;
+
+namespace Jellyfin.Extensions.Tests;
+
+public static class FileHelperTests
+{
+ [Fact]
+ public static void CreateEmpty_Valid_Correct()
+ {
+ var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName());
+ var fileInfo = new FileInfo(path);
+
+ Assert.False(fileInfo.Exists);
+
+ FileHelper.CreateEmpty(path);
+
+ fileInfo.Refresh();
+ Assert.True(fileInfo.Exists);
+
+ File.Delete(path);
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
index 0fb7894e5..b71dc1520 100644
--- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
@@ -54,7 +54,7 @@ public class XmlTvListingsProviderTests
Path = path
};
- var startDate = new DateTime(2022, 11, 4);
+ 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 programsList = programs.ToList();
Assert.Single(programsList);
@@ -78,7 +78,7 @@ public class XmlTvListingsProviderTests
Path = path
};
- var startDate = new DateTime(2022, 11, 4);
+ 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 programsList = programs.ToList();
Assert.Single(programsList);
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml
index dd4aa8977..487b02893 100644
--- a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/emptycategory.xml
@@ -1,5 +1,5 @@
<tv date="20221104">
- <programme channel="3297" start="20221104130000 -0400" stop="20221105235959 -0400">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221105235959 +0000">
<category lang="en" />
<category lang="en">sports</category>
</programme>
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml
index 5a5be7997..b3cef41f3 100644
--- a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml
@@ -1,5 +1,5 @@
<tv date="20221104">
- <programme channel="3297" start="20221104130000 -0400" stop="20221105235959 -0400">
+ <programme channel="3297" start="20221104130000 +0000" stop="20221105235959 +0000">
<category lang="en">sports</category>
<episode-num system="original-air-date">2022-11-04 13:00:00</episode-num>
<icon height="" src="https://domain.tld/image.png" width=""/>
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
index 4c8ba58d0..4dbe769bf 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeasonPathParserTests.cs
@@ -7,23 +7,38 @@ public class SeasonPathParserTests
{
[Theory]
[InlineData("/Drive/Season 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEASON 1", "/Drive", 1, true)]
[InlineData("/Drive/Staffel 1", "/Drive", 1, true)]
+ [InlineData("/Drive/STAFFEL 1", "/Drive", 1, true)]
[InlineData("/Drive/Stagione 1", "/Drive", 1, true)]
+ [InlineData("/Drive/STAGIONE 1", "/Drive", 1, true)]
[InlineData("/Drive/sæson 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SÆSON 1", "/Drive", 1, true)]
[InlineData("/Drive/Temporada 1", "/Drive", 1, true)]
+ [InlineData("/Drive/TEMPORADA 1", "/Drive", 1, true)]
[InlineData("/Drive/series 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SERIES 1", "/Drive", 1, true)]
[InlineData("/Drive/Kausi 1", "/Drive", 1, true)]
+ [InlineData("/Drive/KAUSI 1", "/Drive", 1, true)]
[InlineData("/Drive/Säsong 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SÄSONG 1", "/Drive", 1, true)]
[InlineData("/Drive/Seizoen 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEIZOEN 1", "/Drive", 1, true)]
[InlineData("/Drive/Seasong 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEASONG 1", "/Drive", 1, true)]
[InlineData("/Drive/Sezon 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEZON 1", "/Drive", 1, true)]
[InlineData("/Drive/sezona 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEZONA 1", "/Drive", 1, true)]
[InlineData("/Drive/sezóna 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEZÓNA 1", "/Drive", 1, true)]
[InlineData("/Drive/Sezonul 1", "/Drive", 1, true)]
+ [InlineData("/Drive/SEZONUL 1", "/Drive", 1, true)]
[InlineData("/Drive/시즌 1", "/Drive", 1, true)]
[InlineData("/Drive/シーズン 1", "/Drive", 1, true)]
[InlineData("/Drive/сезон 1", "/Drive", 1, true)]
[InlineData("/Drive/Сезон 1", "/Drive", 1, true)]
+ [InlineData("/Drive/СЕЗОН 1", "/Drive", 1, true)]
[InlineData("/Drive/Season 10", "/Drive", 10, true)]
[InlineData("/Drive/Season 100", "/Drive", 100, true)]
[InlineData("/Drive/s1", "/Drive", 1, true)]
@@ -46,8 +61,20 @@ public class SeasonPathParserTests
[InlineData("/Drive/s06e05", "/Drive", null, false)]
[InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", "/Drive", null, false)]
[InlineData("/Drive/extras", "/Drive", 0, true)]
+ [InlineData("/Drive/EXTRAS", "/Drive", 0, true)]
[InlineData("/Drive/specials", "/Drive", 0, true)]
+ [InlineData("/Drive/SPECIALS", "/Drive", 0, true)]
[InlineData("/Drive/Episode 1 Season 2", "/Drive", null, false)]
+ [InlineData("/Drive/Episode 1 SEASON 2", "/Drive", null, false)]
+ [InlineData("/media/YouTube/Devyn Johnston/2024-01-24 4070 Ti SUPER in under 7 minutes", "/media/YouTube/Devyn Johnston", null, false)]
+ [InlineData("/media/YouTube/Devyn Johnston/2025-01-28 5090 vs 2 SFF Cases", "/media/YouTube/Devyn Johnston", null, false)]
+ [InlineData("/Drive/202401244070", "/Drive", null, false)]
+ [InlineData("/Drive/Drive.S01.2160p.WEB-DL.DDP5.1.H.265-XXXX", "/Drive", 1, true)]
+ [InlineData("The Wonder Years/The.Wonder.Years.S04.1080p.PDTV.x264-JCH", "/The Wonder Years", 4, true)]
+ [InlineData("The Wonder Years/[The.Wonder.Years.S04.1080p.PDTV.x264-JCH]", "/The Wonder Years", 4, true)]
+ [InlineData("The Wonder Years/The.Wonder.Years [S04][1080p.PDTV.x264-JCH]", "/The Wonder Years", 4, true)]
+ [InlineData("The Wonder Years/The Wonder Years Season 01 1080p", "/The Wonder Years", 1, true)]
+
public void GetSeasonNumberFromPathTest(string path, string? parentPath, int? seasonNumber, bool isSeasonDirectory)
{
var result = SeasonPathParser.Parse(path, parentPath, true, true);
diff --git a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
index 84758c9c3..b81b7934c 100644
--- a/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/SeriesResolverTests.cs
@@ -19,6 +19,7 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("/some/path/The Show", "The Show")]
[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")]
public void SeriesResolverResolveTest(string path, string name)
{
var res = SeriesResolver.Resolve(_namingOptions, path);
diff --git a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs
index 51eb99f49..6d887c577 100644
--- a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs
@@ -23,7 +23,7 @@ namespace Jellyfin.Naming.Tests.Video
Test("300-trailer.mp4", ExtraType.Trailer);
Test("300.trailer.mp4", ExtraType.Trailer);
Test("300_trailer.mp4", ExtraType.Trailer);
- Test("300 trailer.mp4", ExtraType.Trailer);
+ Test("300 - trailer.mp4", ExtraType.Trailer);
Test("theme.mp3", ExtraType.ThemeSong);
}
@@ -132,7 +132,14 @@ namespace Jellyfin.Naming.Tests.Video
Test("300-sample.mp4", ExtraType.Sample);
Test("300.sample.mp4", ExtraType.Sample);
Test("300_sample.mp4", ExtraType.Sample);
- Test("300 sample.mp4", ExtraType.Sample);
+ Test("300 - sample.mp4", ExtraType.Sample);
+ }
+
+ [Fact]
+ public void TestSuffixPartOfTitle()
+ {
+ Test("I Live In A Trailer.mp4", null);
+ Test("The DNA Sample.mp4", null);
}
private void Test(string input, ExtraType? expectedType)
diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs
index 377f82eac..d3164ba9c 100644
--- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs
@@ -87,7 +87,7 @@ namespace Jellyfin.Naming.Tests.Video
var files = new[]
{
"300.mkv",
- "300 trailer.mkv"
+ "300 - trailer.mkv"
};
var result = VideoListResolver.Resolve(
diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
index ef87e46a7..38208476f 100644
--- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
+++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
@@ -281,11 +281,11 @@ namespace Jellyfin.Networking.Tests
}
[Theory]
- [InlineData("185.10.10.10,200.200.200.200", "79.2.3.4", true)]
- [InlineData("185.10.10.10", "185.10.10.10", false)]
- [InlineData("", "100.100.100.100", false)]
+ [InlineData("185.10.10.10,200.200.200.200", "79.2.3.4", RemoteAccessPolicyResult.RejectDueToNotAllowlistedRemoteIP)]
+ [InlineData("185.10.10.10", "185.10.10.10", RemoteAccessPolicyResult.Allow)]
+ [InlineData("", "100.100.100.100", RemoteAccessPolicyResult.Allow)]
- public void HasRemoteAccess_GivenWhitelist_AllowsOnlyIPsInWhitelist(string addresses, string remoteIP, bool denied)
+ public void HasRemoteAccess_GivenWhitelist_AllowsOnlyIPsInWhitelist(string addresses, string remoteIP, RemoteAccessPolicyResult expectedResult)
{
// Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
// If left blank, all remote addresses will be allowed.
@@ -299,15 +299,38 @@ namespace Jellyfin.Networking.Tests
var startupConf = new Mock<IConfiguration>();
using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>());
- Assert.NotEqual(nm.HasRemoteAccess(IPAddress.Parse(remoteIP)), denied);
+ Assert.Equal(expectedResult, nm.ShouldAllowServerAccess(IPAddress.Parse(remoteIP)));
}
[Theory]
- [InlineData("185.10.10.10", "79.2.3.4", false)]
- [InlineData("185.10.10.10", "185.10.10.10", true)]
- [InlineData("", "100.100.100.100", false)]
+ [InlineData("185.10.10.10,200.200.200.200", "79.2.3.4", RemoteAccessPolicyResult.RejectDueToRemoteAccessDisabled)]
+ [InlineData("185.10.10.10", "127.0.0.1", RemoteAccessPolicyResult.Allow)]
+ [InlineData("", "100.100.100.100", RemoteAccessPolicyResult.RejectDueToRemoteAccessDisabled)]
- public void HasRemoteAccess_GivenBlacklist_BlacklistTheIPs(string addresses, string remoteIP, bool denied)
+ public void HasRemoteAccess_GivenRemoteAccessDisabled_IgnoresAllowlist(string addresses, string remoteIP, RemoteAccessPolicyResult expectedResult)
+ {
+ // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
+ // If left blank, all remote addresses will be allowed.
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPv4 = true,
+ EnableRemoteAccess = false,
+ RemoteIPFilter = addresses.Split(','),
+ IsRemoteIPFilterBlacklist = false
+ };
+
+ var startupConf = new Mock<IConfiguration>();
+ using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>());
+
+ Assert.Equal(expectedResult, nm.ShouldAllowServerAccess(IPAddress.Parse(remoteIP)));
+ }
+
+ [Theory]
+ [InlineData("185.10.10.10", "79.2.3.4", RemoteAccessPolicyResult.Allow)]
+ [InlineData("185.10.10.10", "185.10.10.10", RemoteAccessPolicyResult.RejectDueToIPBlocklist)]
+ [InlineData("", "100.100.100.100", RemoteAccessPolicyResult.Allow)]
+
+ public void HasRemoteAccess_GivenBlacklist_BlacklistTheIPs(string addresses, string remoteIP, RemoteAccessPolicyResult expectedResult)
{
// Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
// If left blank, all remote addresses will be allowed.
@@ -321,7 +344,7 @@ namespace Jellyfin.Networking.Tests
var startupConf = new Mock<IConfiguration>();
using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>());
- Assert.NotEqual(nm.HasRemoteAccess(IPAddress.Parse(remoteIP)), denied);
+ Assert.Equal(expectedResult, nm.ShouldAllowServerAccess(IPAddress.Parse(remoteIP)));
}
[Theory]
diff --git a/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs b/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs
index 756a688ab..a1fc067cc 100644
--- a/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Lyrics/LrcLyricParserTests.cs
@@ -20,22 +20,28 @@ public static class LrcLyricParserTests
var line1 = parsed.Lyrics[0];
Assert.Equal("Every night that goes between", line1.Text);
Assert.NotNull(line1.Cues);
- Assert.Equal(9, line1.Cues.Count);
+ Assert.Equal(5, line1.Cues.Count);
Assert.Equal(68400000, line1.Cues[0].Start);
Assert.Equal(72000000, line1.Cues[0].End);
+ Assert.Equal(0, line1.Cues[0].Position);
+ Assert.Equal(5, line1.Cues[0].EndPosition);
+ Assert.Equal(6, line1.Cues[1].Position);
+ Assert.Equal(11, line1.Cues[1].EndPosition);
+ Assert.Equal(12, line1.Cues[2].Position);
var line5 = parsed.Lyrics[4];
Assert.Equal("Every night you do not come", line5.Text);
Assert.NotNull(line5.Cues);
- Assert.Equal(11, line5.Cues.Count);
- Assert.Equal(377300000, line5.Cues[5].Start);
- Assert.Equal(380000000, line5.Cues[5].End);
+ Assert.Equal(6, line5.Cues.Count);
+ Assert.Equal(375200000, line5.Cues[2].Start);
+ Assert.Equal(377300000, line5.Cues[2].End);
var lastLine = parsed.Lyrics[^1];
Assert.Equal("I have always been a storm", lastLine.Text);
Assert.NotNull(lastLine.Cues);
- Assert.Equal(11, lastLine.Cues.Count);
+ Assert.Equal(6, lastLine.Cues.Count);
Assert.Equal(2358000000, lastLine.Cues[^1].Start);
+ Assert.Equal(26, lastLine.Cues[^1].EndPosition);
Assert.Null(lastLine.Cues[^1].End);
}
}
diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs
index b32ecf6ec..cdebdadfb 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs
@@ -22,7 +22,7 @@ namespace Jellyfin.Providers.Tests.Manager
{
var newLocked = new[] { MetadataField.Genres, MetadataField.Cast };
var newString = "new";
- var newDate = DateTime.Now;
+ var newDate = DateTime.UtcNow;
var oldLocked = new[] { MetadataField.Genres };
var oldString = "old";
@@ -39,6 +39,7 @@ namespace Jellyfin.Providers.Tests.Manager
DateCreated = newDate
}
};
+
if (defaultDate)
{
source.Item.DateCreated = default;
@@ -141,8 +142,8 @@ namespace Jellyfin.Providers.Tests.Manager
{ "ProductionYear", 1, 2 },
{ "CommunityRating", 1.0f, 2.0f },
{ "CriticRating", 1.0f, 2.0f },
- { "EndDate", DateTime.UnixEpoch, DateTime.Now },
- { "PremiereDate", DateTime.UnixEpoch, DateTime.Now },
+ { "EndDate", DateTime.UnixEpoch, DateTime.UtcNow },
+ { "PremiereDate", DateTime.UnixEpoch, DateTime.UtcNow },
{ "Video3DFormat", Video3DFormat.HalfSideBySide, Video3DFormat.FullSideBySide }
};
@@ -156,7 +157,17 @@ namespace Jellyfin.Providers.Tests.Manager
Assert.True(TestMergeBaseItemData<Movie, MovieInfo>(propName, oldValue, newValue, null, true, out _));
Assert.True(TestMergeBaseItemData<Movie, MovieInfo>(propName, null, newValue, null, false, out _));
- Assert.True(TestMergeBaseItemData<Movie, MovieInfo>(propName, oldValue, null, null, true, out _));
+ // Video3DFormat - null values do NOT replace existing data
+ if (string.Equals(propName, "Video3DFormat", StringComparison.Ordinal))
+ {
+ Assert.False(
+ TestMergeBaseItemData<Movie, MovieInfo>(propName, oldValue, null, null, true, out _));
+ }
+ else
+ {
+ Assert.True(
+ TestMergeBaseItemData<Movie, MovieInfo>(propName, oldValue, null, null, true, out _));
+ }
}
[Fact]
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index c227883b5..87e7a4b56 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -12,6 +12,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
+using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Subtitles;
using MediaBrowser.Model.Configuration;
diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
index 6fd48a044..fb0a08c29 100644
--- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs
@@ -29,6 +29,7 @@ namespace Jellyfin.Providers.Tests.Tmdb
[InlineData("fr-CA", "fr-BE", "fr-CA")]
[InlineData("fr-CA", "fr", "fr-CA")]
[InlineData("de", "en-US", "de")]
+ [InlineData("", "en-US", "")]
public static void AdjustImageLanguage_Valid_Success(string imageLanguage, string requestLanguage, string? expected)
{
Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage));
diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
index 95a5b8179..6997b51ac 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
@@ -5,83 +5,119 @@ using System.Runtime.InteropServices;
using AutoFixture;
using AutoFixture.AutoMoq;
using Emby.Server.Implementations.IO;
+using Jellyfin.Extensions;
using Xunit;
-namespace Jellyfin.Server.Implementations.Tests.IO
+namespace Jellyfin.Server.Implementations.Tests.IO;
+
+public class ManagedFileSystemTests
{
- public class ManagedFileSystemTests
+ private readonly IFixture _fixture;
+ private readonly ManagedFileSystem _sut;
+
+ public ManagedFileSystemTests()
+ {
+ _fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
+ _sut = _fixture.Create<ManagedFileSystem>();
+ }
+
+ [Fact]
+ public void MoveDirectory_SameFileSystem_Correct()
+ => MoveDirectoryInternal();
+
+ [SkippableFact]
+ public void MoveDirectory_DifferentFileSystem_Correct()
+ {
+ const string DestinationParent = "/dev/shm";
+
+ Skip.IfNot(Directory.Exists(DestinationParent));
+
+ MoveDirectoryInternal(DestinationParent);
+ }
+
+ internal void MoveDirectoryInternal(string? destinationParent = null)
{
- private readonly IFixture _fixture;
- private readonly ManagedFileSystem _sut;
-
- public ManagedFileSystemTests()
- {
- _fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
- _sut = _fixture.Create<ManagedFileSystem>();
- }
-
- [SkippableTheory]
- [InlineData("/Volumes/Library/Sample/Music/Playlists/", "../Beethoven/Misc/Moonlight Sonata.mp3", "/Volumes/Library/Sample/Music/Beethoven/Misc/Moonlight Sonata.mp3")]
- [InlineData("/Volumes/Library/Sample/Music/Playlists/", "../../Beethoven/Misc/Moonlight Sonata.mp3", "/Volumes/Library/Sample/Beethoven/Misc/Moonlight Sonata.mp3")]
- [InlineData("/Volumes/Library/Sample/Music/Playlists/", "Beethoven/Misc/Moonlight Sonata.mp3", "/Volumes/Library/Sample/Music/Playlists/Beethoven/Misc/Moonlight Sonata.mp3")]
- [InlineData("/Volumes/Library/Sample/Music/Playlists/", "/mnt/Beethoven/Misc/Moonlight Sonata.mp3", "/mnt/Beethoven/Misc/Moonlight Sonata.mp3")]
- public void MakeAbsolutePathCorrectlyHandlesRelativeFilePathsOnUnixLike(
- string folderPath,
- string filePath,
- string expectedAbsolutePath)
- {
- Skip.If(OperatingSystem.IsWindows());
-
- var generatedPath = _sut.MakeAbsolutePath(folderPath, filePath);
- Assert.Equal(expectedAbsolutePath, generatedPath);
- }
-
- [SkippableTheory]
- [InlineData(@"C:\\Volumes\Library\Sample\Music\Playlists\", @"..\Beethoven\Misc\Moonlight Sonata.mp3", @"C:\Volumes\Library\Sample\Music\Beethoven\Misc\Moonlight Sonata.mp3")]
- [InlineData(@"C:\\Volumes\Library\Sample\Music\Playlists\", @"..\..\Beethoven\Misc\Moonlight Sonata.mp3", @"C:\Volumes\Library\Sample\Beethoven\Misc\Moonlight Sonata.mp3")]
- [InlineData(@"C:\\Volumes\Library\Sample\Music\Playlists\", @"Beethoven\Misc\Moonlight Sonata.mp3", @"C:\Volumes\Library\Sample\Music\Playlists\Beethoven\Misc\Moonlight Sonata.mp3")]
- [InlineData(@"C:\\Volumes\Library\Sample\Music\Playlists\", @"D:\\Beethoven\Misc\Moonlight Sonata.mp3", @"D:\\Beethoven\Misc\Moonlight Sonata.mp3")]
- public void MakeAbsolutePathCorrectlyHandlesRelativeFilePathsOnWindows(
- string folderPath,
- string filePath,
- string expectedAbsolutePath)
- {
- Skip.If(!OperatingSystem.IsWindows());
-
- var generatedPath = _sut.MakeAbsolutePath(folderPath, filePath);
-
- Assert.Equal(expectedAbsolutePath, generatedPath);
- }
-
- [Theory]
- [InlineData("ValidFileName", "ValidFileName")]
- [InlineData("AC/DC", "AC DC")]
- [InlineData("Invalid\0", "Invalid ")]
- [InlineData("AC/DC\0KD/A", "AC DC KD A")]
- public void GetValidFilename_ReturnsValidFilename(string filename, string expectedFileName)
- {
- Assert.Equal(expectedFileName, _sut.GetValidFilename(filename));
- }
-
- [SkippableFact]
- public void GetFileInfo_DanglingSymlink_ExistsFalse()
- {
- Skip.If(OperatingSystem.IsWindows());
-
- string testFileDir = Path.Combine(Path.GetTempPath(), "jellyfin-test-data");
- string testFileName = Path.Combine(testFileDir, Path.GetRandomFileName() + "-danglingsym.link");
-
- Directory.CreateDirectory(testFileDir);
- Assert.Equal(0, symlink("thispathdoesntexist", testFileName));
- Assert.True(File.Exists(testFileName));
-
- var metadata = _sut.GetFileInfo(testFileName);
- Assert.False(metadata.Exists);
- }
-
- [SuppressMessage("Naming Rules", "SA1300:ElementMustBeginWithUpperCaseLetter", Justification = "Have to")]
- [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)]
- [DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)]
- private static extern int symlink(string target, string linkpath);
+ const string TempFile0 = "tempfile0";
+ const string TempFile1 = "tempfile1";
+
+ destinationParent ??= Path.GetTempPath();
+
+ var sourceDir = Directory.CreateTempSubdirectory();
+ var destinationDir = Path.Join(destinationParent, Path.GetRandomFileName());
+ FileHelper.CreateEmpty(Path.Join(sourceDir.FullName, TempFile0));
+ FileHelper.CreateEmpty(Path.Join(sourceDir.FullName, TempFile1));
+
+ _sut.MoveDirectory(sourceDir.FullName, destinationDir);
+
+ Assert.True(Directory.Exists(destinationDir));
+ Assert.True(File.Exists(Path.Join(destinationDir, TempFile0)));
+ Assert.True(File.Exists(Path.Join(destinationDir, TempFile1)));
+ Assert.False(Directory.Exists(sourceDir.FullName));
+
+ Directory.Delete(destinationDir, true);
}
+
+ [SkippableTheory]
+ [InlineData("/Volumes/Library/Sample/Music/Playlists/", "../Beethoven/Misc/Moonlight Sonata.mp3", "/Volumes/Library/Sample/Music/Beethoven/Misc/Moonlight Sonata.mp3")]
+ [InlineData("/Volumes/Library/Sample/Music/Playlists/", "../../Beethoven/Misc/Moonlight Sonata.mp3", "/Volumes/Library/Sample/Beethoven/Misc/Moonlight Sonata.mp3")]
+ [InlineData("/Volumes/Library/Sample/Music/Playlists/", "Beethoven/Misc/Moonlight Sonata.mp3", "/Volumes/Library/Sample/Music/Playlists/Beethoven/Misc/Moonlight Sonata.mp3")]
+ [InlineData("/Volumes/Library/Sample/Music/Playlists/", "/mnt/Beethoven/Misc/Moonlight Sonata.mp3", "/mnt/Beethoven/Misc/Moonlight Sonata.mp3")]
+ public void MakeAbsolutePathCorrectlyHandlesRelativeFilePathsOnUnixLike(
+ string folderPath,
+ string filePath,
+ string expectedAbsolutePath)
+ {
+ Skip.If(OperatingSystem.IsWindows());
+
+ var generatedPath = _sut.MakeAbsolutePath(folderPath, filePath);
+ Assert.Equal(expectedAbsolutePath, generatedPath);
+ }
+
+ [SkippableTheory]
+ [InlineData(@"C:\\Volumes\Library\Sample\Music\Playlists\", @"..\Beethoven\Misc\Moonlight Sonata.mp3", @"C:\Volumes\Library\Sample\Music\Beethoven\Misc\Moonlight Sonata.mp3")]
+ [InlineData(@"C:\\Volumes\Library\Sample\Music\Playlists\", @"..\..\Beethoven\Misc\Moonlight Sonata.mp3", @"C:\Volumes\Library\Sample\Beethoven\Misc\Moonlight Sonata.mp3")]
+ [InlineData(@"C:\\Volumes\Library\Sample\Music\Playlists\", @"Beethoven\Misc\Moonlight Sonata.mp3", @"C:\Volumes\Library\Sample\Music\Playlists\Beethoven\Misc\Moonlight Sonata.mp3")]
+ [InlineData(@"C:\\Volumes\Library\Sample\Music\Playlists\", @"D:\\Beethoven\Misc\Moonlight Sonata.mp3", @"D:\\Beethoven\Misc\Moonlight Sonata.mp3")]
+ public void MakeAbsolutePathCorrectlyHandlesRelativeFilePathsOnWindows(
+ string folderPath,
+ string filePath,
+ string expectedAbsolutePath)
+ {
+ Skip.IfNot(OperatingSystem.IsWindows());
+
+ var generatedPath = _sut.MakeAbsolutePath(folderPath, filePath);
+
+ Assert.Equal(expectedAbsolutePath, generatedPath);
+ }
+
+ [Theory]
+ [InlineData("ValidFileName", "ValidFileName")]
+ [InlineData("AC/DC", "AC DC")]
+ [InlineData("Invalid\0", "Invalid ")]
+ [InlineData("AC/DC\0KD/A", "AC DC KD A")]
+ public void GetValidFilename_ReturnsValidFilename(string filename, string expectedFileName)
+ {
+ Assert.Equal(expectedFileName, _sut.GetValidFilename(filename));
+ }
+
+ [SkippableFact]
+ public void GetFileInfo_DanglingSymlink_ExistsFalse()
+ {
+ Skip.If(OperatingSystem.IsWindows());
+
+ string testFileDir = Path.Combine(Path.GetTempPath(), "jellyfin-test-data");
+ string testFileName = Path.Combine(testFileDir, Path.GetRandomFileName() + "-danglingsym.link");
+
+ Directory.CreateDirectory(testFileDir);
+ Assert.Equal(0, symlink("thispathdoesntexist", testFileName));
+ Assert.True(File.Exists(testFileName));
+
+ var metadata = _sut.GetFileInfo(testFileName);
+ Assert.False(metadata.Exists);
+ }
+
+ [SuppressMessage("Naming Rules", "SA1300:ElementMustBeginWithUpperCaseLetter", Justification = "Have to")]
+ [DllImport("libc", SetLastError = true, CharSet = CharSet.Ansi)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.UserDirectories)]
+ private static extern int symlink(string target, string linkpath);
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs
index caf2b06b7..8ac3e5e31 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/OrderMapperTests.cs
@@ -12,7 +12,7 @@ public class OrderMapperTests
[Fact]
public void ShouldReturnMappedOrderForSortingByPremierDate()
{
- var orderFunc = OrderMapper.MapOrderByField(ItemSortBy.PremiereDate, new InternalItemsQuery()).Compile();
+ var orderFunc = OrderMapper.MapOrderByField(ItemSortBy.PremiereDate, new InternalItemsQuery(), null!).Compile();
var expectedDate = new DateTime(1, 2, 3);
var expectedProductionYearDate = new DateTime(4, 1, 1);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/CoreResolutionIgnoreRuleTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/CoreResolutionIgnoreRuleTest.cs
new file mode 100644
index 000000000..0495c209d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/CoreResolutionIgnoreRuleTest.cs
@@ -0,0 +1,129 @@
+using System;
+using System.IO;
+using Emby.Naming.Common;
+using Emby.Naming.Video;
+using Emby.Server.Implementations.Library;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public class CoreResolutionIgnoreRuleTest
+{
+ private readonly CoreResolutionIgnoreRule _rule;
+ private readonly NamingOptions _namingOptions;
+ private readonly Mock<IServerApplicationPaths> _appPathsMock;
+
+ public CoreResolutionIgnoreRuleTest()
+ {
+ _namingOptions = new NamingOptions();
+
+ _namingOptions.AllExtrasTypesFolderNames.TryAdd("extras", ExtraType.Trailer);
+
+ _appPathsMock = new Mock<IServerApplicationPaths>();
+ _appPathsMock.SetupGet(x => x.RootFolderPath).Returns("/server/root");
+
+ _rule = new CoreResolutionIgnoreRule(_namingOptions, _appPathsMock.Object);
+ }
+
+ private FileSystemMetadata MakeFileSystemMetadata(string fullName, bool isDirectory = false)
+ => new FileSystemMetadata { FullName = fullName, Name = Path.GetFileName(fullName), IsDirectory = isDirectory };
+
+ private BaseItem MakeParent(string name = "Parent", bool isTopParent = false, Type? type = null)
+ {
+ return type switch
+ {
+ Type t when t == typeof(Folder) => CreateMock<Folder>(name, isTopParent).Object,
+ Type t when t == typeof(AggregateFolder) => CreateMock<AggregateFolder>(name, isTopParent).Object,
+ Type t when t == typeof(UserRootFolder) => CreateMock<UserRootFolder>(name, isTopParent).Object,
+ _ => CreateMock<BaseItem>(name, isTopParent).Object
+ };
+ }
+
+ private static Mock<T> CreateMock<T>(string name, bool isTopParent)
+ where T : BaseItem
+ {
+ var mock = new Mock<T>();
+ mock.SetupGet(p => p.Name).Returns(name);
+ mock.SetupGet(p => p.IsTopParent).Returns(isTopParent);
+ return mock;
+ }
+
+ [Fact]
+ public void TestApplicationFolder()
+ {
+ Assert.False(_rule.ShouldIgnore(
+ MakeFileSystemMetadata("/server/root/extras", isDirectory: true),
+ null));
+
+ Assert.False(_rule.ShouldIgnore(
+ MakeFileSystemMetadata("/server/root/small.jpg"),
+ null));
+ }
+
+ [Fact]
+ public void TestTopLevelDirectory()
+ {
+ Assert.False(_rule.ShouldIgnore(
+ MakeFileSystemMetadata("Series/Extras", true),
+ MakeParent(type: typeof(AggregateFolder))));
+
+ Assert.False(_rule.ShouldIgnore(
+ MakeFileSystemMetadata("Series/Extras/Extras", true),
+ MakeParent(isTopParent: true)));
+ }
+
+ [Fact]
+ public void TestIgnorePatterns()
+ {
+ Assert.False(_rule.ShouldIgnore(
+ MakeFileSystemMetadata("/Media/big.jpg"),
+ MakeParent()));
+
+ Assert.True(_rule.ShouldIgnore(
+ MakeFileSystemMetadata("/Media/small.jpg"),
+ MakeParent()));
+ }
+
+ [Fact]
+ public void TestExtrasTypesFolderNames()
+ {
+ FileSystemMetadata fileSystemMetadata = MakeFileSystemMetadata("/Movies/Up/extras", true);
+
+ Assert.False(_rule.ShouldIgnore(
+ fileSystemMetadata,
+ MakeParent(type: typeof(AggregateFolder))));
+
+ Assert.False(_rule.ShouldIgnore(
+ fileSystemMetadata,
+ MakeParent(type: typeof(UserRootFolder))));
+
+ Assert.False(_rule.ShouldIgnore(
+ fileSystemMetadata,
+ null));
+
+ Assert.True(_rule.ShouldIgnore(
+ fileSystemMetadata,
+ MakeParent()));
+
+ Assert.True(_rule.ShouldIgnore(
+ fileSystemMetadata,
+ MakeParent(type: typeof(Folder))));
+ }
+
+ [Fact]
+ public void TestThemeSong()
+ {
+ Assert.False(_rule.ShouldIgnore(
+ MakeFileSystemMetadata("/Movies/Up/intro.mp3"),
+ MakeParent()));
+
+ Assert.True(_rule.ShouldIgnore(
+ MakeFileSystemMetadata("/Movies/Up/theme.mp3"),
+ MakeParent()));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
new file mode 100644
index 000000000..d677c9f09
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/DotIgnoreIgnoreRuleTest.cs
@@ -0,0 +1,30 @@
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+public class DotIgnoreIgnoreRuleTest
+{
+ [Fact]
+ public void Test()
+ {
+ var ignore = new Ignore.Ignore();
+ ignore.Add("SPs");
+ Assert.True(ignore.IsIgnored("f:/cd/sps/ffffff.mkv"));
+ Assert.True(ignore.IsIgnored("cd/sps/ffffff.mkv"));
+ Assert.True(ignore.IsIgnored("/cd/sps/ffffff.mkv"));
+ }
+
+ [Fact]
+ public void TestNegatePattern()
+ {
+ var ignore = new Ignore.Ignore();
+ ignore.Add("SPs");
+ ignore.Add("!thebestshot.mkv");
+ Assert.True(ignore.IsIgnored("f:/cd/sps/ffffff.mkv"));
+ Assert.True(ignore.IsIgnored("cd/sps/ffffff.mkv"));
+ Assert.True(ignore.IsIgnored("/cd/sps/ffffff.mkv"));
+ Assert.True(!ignore.IsIgnored("f:/cd/sps/thebestshot.mkv"));
+ Assert.True(!ignore.IsIgnored("cd/sps/thebestshot.mkv"));
+ Assert.True(!ignore.IsIgnored("/cd/sps/thebestshot.mkv"));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index a7a1e5e81..6d6bba4fc 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -41,7 +41,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
await localizationManager.LoadAll();
var cultures = localizationManager.GetCultures().ToList();
- Assert.Equal(191, cultures.Count);
+ Assert.Equal(194, cultures.Count);
var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal));
Assert.NotNull(germany);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
index 934024826..3d8ea15a3 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
@@ -6,6 +6,7 @@ using System.Threading.Tasks;
using AutoFixture;
using Emby.Server.Implementations.Library;
using Emby.Server.Implementations.Plugins;
+using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using Jellyfin.Extensions.Json.Converters;
using MediaBrowser.Common.Plugins;
@@ -85,7 +86,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
var dllPath = Path.GetDirectoryName(Path.Combine(_pluginPath, dllFile))!;
Directory.CreateDirectory(dllPath);
- File.Create(Path.Combine(dllPath, filename));
+ FileHelper.CreateEmpty(Path.Combine(dllPath, filename));
var metafilePath = Path.Combine(_pluginPath, "meta.json");
File.WriteAllText(metafilePath, JsonSerializer.Serialize(manifest, _options));
@@ -141,7 +142,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
foreach (var file in files)
{
- File.Create(Path.Combine(_pluginPath, file));
+ FileHelper.CreateEmpty(Path.Combine(_pluginPath, file));
}
var metafilePath = Path.Combine(_pluginPath, "meta.json");
@@ -184,7 +185,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
Description = packageInfo.Description,
Overview = packageInfo.Overview,
TargetAbi = packageInfo.Versions[0].TargetAbi!,
- Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture),
+ Timestamp = DateTimeOffset.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture).UtcDateTime,
Changelog = packageInfo.Versions[0].Changelog!,
Version = new Version(1, 0).ToString(),
ImagePath = string.Empty
@@ -220,7 +221,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
Description = packageInfo.Description,
Overview = packageInfo.Overview,
TargetAbi = packageInfo.Versions[0].TargetAbi!,
- Timestamp = DateTime.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture),
+ Timestamp = DateTimeOffset.Parse(packageInfo.Versions[0].Timestamp!, CultureInfo.InvariantCulture).UtcDateTime,
Changelog = packageInfo.Versions[0].Changelog!,
Version = packageInfo.Versions[0].Version,
ImagePath = string.Empty
@@ -300,7 +301,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
var versionInfo = fixture.Create<VersionInfo>();
versionInfo.Version = new Version(1, 0).ToString();
- versionInfo.Timestamp = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture);
+ versionInfo.Timestamp = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture);
var packageInfo = fixture.Create<PackageInfo>();
packageInfo.Versions = new[] { versionInfo };
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
index e7166d424..36f1b726d 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
@@ -79,6 +79,8 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl
using var createResponse = await client.PostAsJsonAsync("Library/VirtualFolders?name=test&refreshLibrary=true", createBody, _jsonOptions);
Assert.Equal(HttpStatusCode.NoContent, createResponse.StatusCode);
+ await Task.Delay(2000).ConfigureAwait(true);
+
using var response = await client.GetAsync("Library/VirtualFolders");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
index 16c63ed49..04d1b3dc2 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs
@@ -61,7 +61,6 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
var users = await response.Content.ReadFromJsonAsync<UserDto[]>(_jsonOptions);
Assert.NotNull(users);
Assert.Single(users);
- Assert.False(users![0].HasConfiguredPassword);
}
[Fact]
@@ -92,8 +91,6 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var user = await response.Content.ReadFromJsonAsync<UserDto>(_jsonOptions);
Assert.Equal(TestUsername, user!.Name);
- Assert.False(user.HasPassword);
- Assert.False(user.HasConfiguredPassword);
_testUserId = user.Id;
@@ -149,12 +146,6 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
using var response = await UpdateUserPassword(client, _testUserId, createRequest);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
-
- var users = await JsonSerializer.DeserializeAsync<UserDto[]>(
- await client.GetStreamAsync("Users"), _jsonOptions);
- var user = users!.First(x => x.Id.Equals(_testUserId));
- Assert.True(user.HasPassword);
- Assert.True(user.HasConfiguredPassword);
}
[Fact]
@@ -172,12 +163,6 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
using var response = await UpdateUserPassword(client, _testUserId, createRequest);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
-
- var users = await JsonSerializer.DeserializeAsync<UserDto[]>(
- await client.GetStreamAsync("Users"), _jsonOptions);
- var user = users!.First(x => x.Id.Equals(_testUserId));
- Assert.False(user.HasPassword);
- Assert.False(user.HasConfiguredPassword);
}
}
}
diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
index a7fec2960..0952fb8b6 100644
--- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
@@ -5,7 +5,9 @@ using System.IO;
using Emby.Server.Implementations;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Helpers;
+using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
@@ -15,6 +17,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Serilog;
+using Serilog.Core;
using Serilog.Extensions.Logging;
namespace Jellyfin.Server.Integration.Tests
@@ -94,7 +97,11 @@ namespace Jellyfin.Server.Integration.Tests
.AddInMemoryCollection(ConfigurationOptions.DefaultConfiguration)
.AddEnvironmentVariables("JELLYFIN_")
.AddInMemoryCollection(commandLineOpts.ConvertToConfig());
- });
+ })
+ .ConfigureServices(e => e
+ .AddSingleton<IStartupLogger, NullStartupLogger<object>>()
+ .AddTransient(typeof(IStartupLogger<>), typeof(NullStartupLogger<>))
+ .AddSingleton(e));
}
/// <inheritdoc/>
@@ -103,7 +110,11 @@ namespace Jellyfin.Server.Integration.Tests
var host = builder.Build();
var appHost = (TestAppHost)host.Services.GetRequiredService<IApplicationHost>();
appHost.ServiceProvider = host.Services;
+ var applicationPaths = appHost.ServiceProvider.GetRequiredService<IApplicationPaths>();
+ Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService<IConfiguration>()).GetAwaiter().GetResult();
+ Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisation).GetAwaiter().GetResult();
appHost.InitializeServices(Mock.Of<IConfiguration>()).GetAwaiter().GetResult();
+ Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.AppInitialisation).GetAwaiter().GetResult();
host.Start();
appHost.RunStartupTasksAsync().GetAwaiter().GetResult();
@@ -123,5 +134,61 @@ namespace Jellyfin.Server.Integration.Tests
base.Dispose(disposing);
}
+
+ private sealed class NullStartupLogger<TCategory> : IStartupLogger<TCategory>
+ {
+ public StartupLogTopic? Topic => throw new NotImplementedException();
+
+ public IStartupLogger BeginGroup(FormattableString logEntry)
+ {
+ return this;
+ }
+
+ public IStartupLogger<TCategory1> BeginGroup<TCategory1>(FormattableString logEntry)
+ {
+ return new NullStartupLogger<TCategory1>();
+ }
+
+ public IDisposable? BeginScope<TState>(TState state)
+ where TState : notnull
+ {
+ return NullLogger.Instance.BeginScope(state);
+ }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ return NullLogger.Instance.IsEnabled(logLevel);
+ }
+
+ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
+ {
+ NullLogger.Instance.Log(logLevel, eventId, state, exception, formatter);
+ }
+
+ public Microsoft.Extensions.Logging.ILogger With(Microsoft.Extensions.Logging.ILogger logger)
+ {
+ return this;
+ }
+
+ public IStartupLogger<TCategory1> With<TCategory1>(Microsoft.Extensions.Logging.ILogger logger)
+ {
+ return new NullStartupLogger<TCategory1>();
+ }
+
+ IStartupLogger<TCategory> IStartupLogger<TCategory>.BeginGroup(FormattableString logEntry)
+ {
+ return new NullStartupLogger<TCategory>();
+ }
+
+ IStartupLogger IStartupLogger.With(Microsoft.Extensions.Logging.ILogger logger)
+ {
+ return this;
+ }
+
+ IStartupLogger<TCategory> IStartupLogger<TCategory>.With(Microsoft.Extensions.Logging.ILogger logger)
+ {
+ return this;
+ }
+ }
}
}
diff --git a/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs b/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs
index 98195a294..62cdd25ae 100644
--- a/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs
@@ -1,6 +1,7 @@
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
+using MediaBrowser.Model.IO;
using Xunit;
using Xunit.Abstractions;
@@ -33,7 +34,7 @@ namespace Jellyfin.Server.Integration.Tests
// Write out for publishing
string outputPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? ".", "openapi.json"));
_outputHelper.WriteLine("Writing OpenAPI Spec JSON to '{0}'.", outputPath);
- await using var fs = File.Create(outputPath);
+ await using var fs = AsyncFile.Create(outputPath);
await response.Content.CopyToAsync(fs);
}
}
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
index e422eb9b8..1e8652f4b 100644
--- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
@@ -275,5 +275,24 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers
Assert.StartsWith(">>", item.Overview, StringComparison.InvariantCulture);
Assert.EndsWith("<<", item.Overview, StringComparison.InvariantCulture);
}
+
+ [Fact]
+ public void Parse_TmdbcolUniqueId_NormalizedToTmdbCollection()
+ {
+ var result = new MetadataResult<Video>()
+ {
+ Item = new Movie()
+ };
+
+ _parser.Fetch(result, "Test Data/Lilo & Stitch.nfo", CancellationToken.None);
+ var item = (Movie)result.Item;
+
+ // Verify that <uniqueid type="tmdbcol"> is normalized to TmdbCollection
+ Assert.True(item.ProviderIds.ContainsKey(MetadataProvider.TmdbCollection.ToString()));
+ Assert.Equal("97020", item.ProviderIds[MetadataProvider.TmdbCollection.ToString()]);
+
+ // Verify that the lowercase "tmdbcol" is NOT in the provider IDs
+ Assert.False(item.ProviderIds.ContainsKey("tmdbcol"));
+ }
}
}
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Lilo & Stitch.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Lilo & Stitch.nfo
index 1eab687a2..ca0be5dcc 100644
--- a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Lilo & Stitch.nfo
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Lilo & Stitch.nfo
@@ -2,6 +2,7 @@
<movie>
<title>Lilo &amp; Stitch</title>
<originaltitle>Lilo &amp; Stitch</originaltitle>
+ <uniqueid type="tmdbcol" default="false">97020</uniqueid>
<set>Lilo &amp; Stitch Collection</set>
<plot>&gt;&gt;As Stitch, a runaway genetic experiment from a faraway planet, wreaks havoc on the Hawaiian Islands, he becomes the mischievous adopted alien "puppy" of an independent little girl named Lilo and learns about loyalty, friendship, and ʻohana, the Hawaiian tradition of family.&lt;&lt;</plot>
</movie>