aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNyanmisaka <nst799610810@gmail.com>2024-07-23 15:37:33 +0800
committerGitHub <noreply@github.com>2024-07-23 15:37:33 +0800
commit00088c295445fe2710cae468e1b09f98a32e40a5 (patch)
tree77614fb434409bc2ddf3d7d0b5830339a6374bfb
parentdeb36eeedaba2f1421b92d290d85d45bfe48d1f5 (diff)
parent19dca018b2604ff8666cabaf9d0f9c8974572756 (diff)
Merge branch 'master' into fix-hwa-video-rotation
-rw-r--r--.config/dotnet-tools.json2
-rw-r--r--.github/ISSUE_TEMPLATE/issue report.yml160
-rw-r--r--.github/workflows/ci-codeql-analysis.yml10
-rw-r--r--.github/workflows/ci-openapi.yml135
-rw-r--r--.github/workflows/ci-tests.yml6
-rw-r--r--.github/workflows/commands.yml8
-rw-r--r--.github/workflows/issue-template-check.yml4
-rw-r--r--.github/workflows/pull-request-conflict.yml2
-rw-r--r--.github/workflows/release-bump-version.yaml4
-rw-r--r--CONTRIBUTORS.md4
-rw-r--r--Directory.Packages.props61
-rw-r--r--Emby.Naming/Common/NamingOptions.cs6
-rw-r--r--Emby.Naming/Emby.Naming.csproj2
-rw-r--r--Emby.Naming/ExternalFiles/ExternalPathParser.cs2
-rw-r--r--Emby.Naming/TV/TvParserHelpers.cs44
-rw-r--r--Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs2
-rw-r--r--Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs8
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs19
-rw-r--r--Emby.Server.Implementations/Collections/CollectionManager.cs2
-rw-r--r--Emby.Server.Implementations/ConfigurationOptions.cs3
-rw-r--r--Emby.Server.Implementations/Data/BaseSqliteRepository.cs97
-rw-r--r--Emby.Server.Implementations/Data/ManagedConnection.cs62
-rw-r--r--Emby.Server.Implementations/Data/SqliteItemRepository.cs134
-rw-r--r--Emby.Server.Implementations/Data/SqliteUserDataRepository.cs11
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs27
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs31
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketManager.cs20
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs25
-rw-r--r--Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs1
-rw-r--r--Emby.Server.Implementations/Images/BaseFolderImageProvider.cs9
-rw-r--r--Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs11
-rw-r--r--Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs2
-rw-r--r--Emby.Server.Implementations/Library/IgnorePatterns.cs2
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs303
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs30
-rw-r--r--Emby.Server.Implementations/Library/MediaStreamSelector.cs6
-rw-r--r--Emby.Server.Implementations/Library/PathExtensions.cs5
-rw-r--r--Emby.Server.Implementations/Library/ResolverHelper.cs4
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs1
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs8
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs2
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs9
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs30
-rw-r--r--Emby.Server.Implementations/Library/UserViewManager.cs4
-rw-r--r--Emby.Server.Implementations/Library/Validators/PeopleValidator.cs7
-rw-r--r--Emby.Server.Implementations/Localization/Core/ab.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/af.json22
-rw-r--r--Emby.Server.Implementations/Localization/Core/ar.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/be.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/ca.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json14
-rw-r--r--Emby.Server.Implementations/Localization/Core/de.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-GB.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-US.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-MX.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_419.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/es_DO.json110
-rw-r--r--Emby.Server.Implementations/Localization/Core/et.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/fa.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr-CA.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json15
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/hi.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json36
-rw-r--r--Emby.Server.Implementations/Localization/Core/id.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/is.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json54
-rw-r--r--Emby.Server.Implementations/Localization/Core/ja.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/ko.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/lt-LT.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/ml.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/mt.json133
-rw-r--r--Emby.Server.Implementations/Localization/Core/my.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/nb.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/nl.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/nn.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/pa.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/pl.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-BR.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/ru.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/sv.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/ta.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/th.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/uk.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/uz.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json10
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json8
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json80
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs21
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/au.csv2
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs212
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs7
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs206
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs1
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs11
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs44
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs54
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs32
-rw-r--r--Emby.Server.Implementations/Serialization/MyXmlSerializer.cs5
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs19
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs114
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs2
-rw-r--r--Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs40
-rw-r--r--Jellyfin.Api/Controllers/AudioController.cs14
-rw-r--r--Jellyfin.Api/Controllers/DashboardController.cs3
-rw-r--r--Jellyfin.Api/Controllers/DisplayPreferencesController.cs2
-rw-r--r--Jellyfin.Api/Controllers/DynamicHlsController.cs87
-rw-r--r--Jellyfin.Api/Controllers/FilterController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ImageController.cs20
-rw-r--r--Jellyfin.Api/Controllers/InstantMixController.cs60
-rw-r--r--Jellyfin.Api/Controllers/ItemLookupController.cs13
-rw-r--r--Jellyfin.Api/Controllers/ItemRefreshController.cs8
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs82
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs29
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs113
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs46
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs27
-rw-r--r--Jellyfin.Api/Controllers/LyricsController.cs58
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs42
-rw-r--r--Jellyfin.Api/Controllers/PackageController.cs5
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs314
-rw-r--r--Jellyfin.Api/Controllers/PlaystateController.cs21
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs6
-rw-r--r--Jellyfin.Api/Controllers/RemoteImageController.cs9
-rw-r--r--Jellyfin.Api/Controllers/SearchController.cs2
-rw-r--r--Jellyfin.Api/Controllers/SessionController.cs8
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs52
-rw-r--r--Jellyfin.Api/Controllers/TrailersController.cs1
-rw-r--r--Jellyfin.Api/Controllers/TrickplayController.cs4
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs22
-rw-r--r--Jellyfin.Api/Controllers/UniversalAudioController.cs50
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs116
-rw-r--r--Jellyfin.Api/Controllers/UserViewsController.cs9
-rw-r--r--Jellyfin.Api/Controllers/VideoAttachmentsController.cs5
-rw-r--r--Jellyfin.Api/Controllers/VideosController.cs45
-rw-r--r--Jellyfin.Api/Extensions/DtoExtensions.cs12
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs23
-rw-r--r--Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs70
-rw-r--r--Jellyfin.Api/Helpers/MediaInfoHelper.cs16
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs11
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs33
-rw-r--r--Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs2
-rw-r--r--Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs15
-rw-r--r--Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs34
-rw-r--r--Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs12
-rw-r--r--Jellyfin.Data/Jellyfin.Data.csproj2
-rw-r--r--Jellyfin.Server.Implementations/Devices/DeviceManager.cs2
-rw-r--r--Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs27
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs48
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs159
-rw-r--r--Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs11
-rw-r--r--Jellyfin.Server/Helpers/StartupHelpers.cs1
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs32
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixAudioData.cs106
-rw-r--r--Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs6
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs2
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs2
-rw-r--r--Jellyfin.Server/Program.cs1
-rw-r--r--Jellyfin.Server/Startup.cs7
-rw-r--r--MediaBrowser.Common/MediaBrowser.Common.csproj2
-rw-r--r--MediaBrowser.Common/Net/NetworkConstants.cs5
-rw-r--r--MediaBrowser.Controller/Entities/AggregateFolder.cs4
-rw-r--r--MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs12
-rw-r--r--MediaBrowser.Controller/Entities/Audio/MusicArtist.cs4
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs68
-rw-r--r--MediaBrowser.Controller/Entities/CollectionFolder.cs3
-rw-r--r--MediaBrowser.Controller/Entities/Extensions.cs8
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs68
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs3
-rw-r--r--MediaBrowser.Controller/Entities/Movies/Movie.cs21
-rw-r--r--MediaBrowser.Controller/Entities/TV/Episode.cs17
-rw-r--r--MediaBrowser.Controller/Entities/TV/Season.cs19
-rw-r--r--MediaBrowser.Controller/Entities/TV/Series.cs51
-rw-r--r--MediaBrowser.Controller/Entities/TagExtensions.cs4
-rw-r--r--MediaBrowser.Controller/Entities/Trailer.cs20
-rw-r--r--MediaBrowser.Controller/Entities/UserRootFolder.cs4
-rw-r--r--MediaBrowser.Controller/Entities/UserView.cs4
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs4
-rw-r--r--MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs15
-rw-r--r--MediaBrowser.Controller/IO/FileSystemHelper.cs64
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs61
-rw-r--r--MediaBrowser.Controller/Library/IMediaSourceManager.cs2
-rw-r--r--MediaBrowser.Controller/Library/ItemResolveArgs.cs4
-rw-r--r--MediaBrowser.Controller/LiveTv/LiveTvConflictException.cs4
-rw-r--r--MediaBrowser.Controller/LiveTv/LiveTvProgram.cs20
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj2
-rw-r--r--MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs2
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs275
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs2
-rw-r--r--MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs17
-rw-r--r--MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs39
-rw-r--r--MediaBrowser.Controller/Playlists/IPlaylistManager.cs46
-rw-r--r--MediaBrowser.Controller/Playlists/Playlist.cs49
-rw-r--r--MediaBrowser.Controller/Providers/DirectoryService.cs32
-rw-r--r--MediaBrowser.Controller/Providers/IDirectoryService.cs6
-rw-r--r--MediaBrowser.Controller/Providers/IExternalId.cs2
-rw-r--r--MediaBrowser.Controller/Providers/IExternalUrlProvider.cs22
-rw-r--r--MediaBrowser.Controller/Providers/IProviderManager.cs12
-rw-r--r--MediaBrowser.Controller/Providers/ItemInfo.cs6
-rw-r--r--MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs2
-rw-r--r--MediaBrowser.Controller/Session/SessionInfo.cs5
-rw-r--r--MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs19
-rw-r--r--MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs1
-rw-r--r--MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs18
-rw-r--r--MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs19
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs41
-rw-r--r--MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs76
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs36
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs138
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs91
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs68
-rw-r--r--MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs23
-rw-r--r--MediaBrowser.Model/Configuration/LibraryOptions.cs6
-rw-r--r--MediaBrowser.Model/Configuration/ServerConfiguration.cs2
-rw-r--r--MediaBrowser.Model/Configuration/TrickplayOptions.cs6
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs89
-rw-r--r--MediaBrowser.Model/Dlna/StreamInfo.cs4
-rw-r--r--MediaBrowser.Model/Dlna/TranscodingProfile.cs4
-rw-r--r--MediaBrowser.Model/Dto/BaseItemDto.cs8
-rw-r--r--MediaBrowser.Model/Entities/CollectionTypeOptions.cs59
-rw-r--r--MediaBrowser.Model/Entities/IHasShares.cs6
-rw-r--r--MediaBrowser.Model/Entities/MediaStream.cs60
-rw-r--r--MediaBrowser.Model/Entities/PlaylistUserPermissions.cs30
-rw-r--r--MediaBrowser.Model/Entities/ProviderIdsExtensions.cs60
-rw-r--r--MediaBrowser.Model/Entities/Share.cs17
-rw-r--r--MediaBrowser.Model/Entities/VirtualFolderInfo.cs3
-rw-r--r--MediaBrowser.Model/MediaBrowser.Model.csproj7
-rw-r--r--MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs11
-rw-r--r--MediaBrowser.Model/Playlists/PlaylistUpdateRequest.cs41
-rw-r--r--MediaBrowser.Model/Playlists/PlaylistUserUpdateRequest.cs24
-rw-r--r--MediaBrowser.Model/Providers/ExternalIdInfo.cs5
-rw-r--r--MediaBrowser.Model/Search/SearchHint.cs2
-rw-r--r--MediaBrowser.Model/Tasks/TaskTriggerInfo.cs1
-rw-r--r--MediaBrowser.Providers/Manager/ImageSaver.cs85
-rw-r--r--MediaBrowser.Providers/Manager/ItemImageProvider.cs23
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs311
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs91
-rw-r--r--MediaBrowser.Providers/MediaInfo/AudioFileProber.cs418
-rw-r--r--MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs11
-rw-r--r--MediaBrowser.Providers/MediaInfo/ProbeProvider.cs27
-rw-r--r--MediaBrowser.Providers/Movies/MovieMetadataService.cs16
-rw-r--r--MediaBrowser.Providers/Movies/TrailerMetadataService.cs21
-rw-r--r--MediaBrowser.Providers/Music/AlbumMetadataService.cs4
-rw-r--r--MediaBrowser.Providers/Music/AudioMetadataService.cs5
-rw-r--r--MediaBrowser.Providers/Music/MusicVideoMetadataService.cs5
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs249
-rw-r--r--MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs21
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs11
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs79
-rw-r--r--MediaBrowser.Providers/TV/SeriesMetadataService.cs88
-rw-r--r--MediaBrowser.Providers/Trickplay/TrickplayProvider.cs2
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs4
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs4
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs19
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs60
-rw-r--r--MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs5
-rw-r--r--MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs89
-rw-r--r--MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs4
-rw-r--r--MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs17
-rw-r--r--README.md22
-rw-r--r--SharedVersion.cs4
-rw-r--r--jellyfin.ruleset6
-rw-r--r--src/Jellyfin.Drawing.Skia/SkiaEncoder.cs17
-rw-r--r--src/Jellyfin.Extensions/Jellyfin.Extensions.csproj4
-rw-r--r--src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs25
-rw-r--r--src/Jellyfin.Extensions/StreamExtensions.cs7
-rw-r--r--src/Jellyfin.LiveTv/Channels/ChannelManager.cs2
-rw-r--r--src/Jellyfin.LiveTv/Listings/ListingsManager.cs14
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs2
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs4
-rw-r--r--src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs12
-rw-r--r--src/Jellyfin.LiveTv/Timers/TimerManager.cs2
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs2
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs2
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs44
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs4
-rw-r--r--src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs7
-rw-r--r--src/Jellyfin.Networking/AutoDiscoveryHost.cs69
-rw-r--r--src/Jellyfin.Networking/Manager/NetworkManager.cs57
-rw-r--r--tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs67
-rw-r--r--tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs68
-rw-r--r--tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs71
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs4
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs2
-rw-r--r--tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs18
-rw-r--r--tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs1
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs31
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs24
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs5
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs6
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs6
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs6
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs2
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs6
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs33
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs6
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs31
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Sorting/PremiereDateComparerTests.cs76
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs114
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs2
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs5
317 files changed, 6170 insertions, 3195 deletions
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index c6670e9f5..af5264279 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
- "version": "8.0.3",
+ "version": "8.0.7",
"commands": [
"dotnet-ef"
]
diff --git a/.github/ISSUE_TEMPLATE/issue report.yml b/.github/ISSUE_TEMPLATE/issue report.yml
index b690b82c2..31ae50263 100644
--- a/.github/ISSUE_TEMPLATE/issue report.yml
+++ b/.github/ISSUE_TEMPLATE/issue report.yml
@@ -1,58 +1,131 @@
name: Issue Report
description: File an issue report
-title: "[Issue]: "
labels: [bug, triage]
body:
- type: markdown
+ id: introduction
attributes:
value: |
- Thanks for taking the time to report an issue. Before submitting a report, please do the following:
- 1. Please head to our forum or chat rooms and troubleshoot with volunteers if you haven't already. Links can be found here: https://jellyfin.org/contact/
- 2. Please search the bug tracker for similar issues. If you do find one, please comment there instead of opening a new bug report.
- 3. If you decide to open a new report, please provide as much detail as possible.
- 4. Please **ONLY** report **ONE** issue per report. If you are experiencing multiple issues, please open multiple reports.
+ ### Thank you for taking the time to report an issue!
+ Please keep in mind that Jellyfin is a [free and open-source](https://jellyfin.org/docs/general/about) project, made up entirely and exclusively of **volunteers** who donate their free time to the project.
+ - type: checkboxes
+ id: before-posting
+ attributes:
+ label: "This issue respects the following points:"
+ description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
+ options:
+ - label: This is a **bug**, not a question or a configuration issue; Please visit our forum or chat rooms first to troubleshoot with volunteers, before creating a report. The links can be found [here](https://jellyfin.org/contact/).
+ required: true
+ - label: This issue is **not** already reported on [GitHub](https://github.com/jellyfin/jellyfin/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
+ required: true
+ - label: I'm using an up to date version of Jellyfin Server stable, unstable or master; We generally do not support previous older versions. If possible, please update to the latest version before opening an issue.
+ required: true
+ - label: I agree to follow Jellyfin's [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct).
+ required: true
+ - label: This report addresses only a single issue; If you encounter multiple issues, kindly create separate reports for each one.
+ required: true
+ - type: markdown
+ id: preliminary-information
+ attributes:
+ value: |
+ ### General preliminary information
+
+ Please keep the following in mind when creating this issue:
+
+ 1. Fill in as much of the template as possible. When you are unsure about the relevancy of a section, do include the information requested in that section. Only leave out information in sections when you are completely sure about it not being relevant.
+ 2. Provide as much detail as possible. Do not assume other people to know what is going on.
+ 3. Keep everything readable and structured. Nobody enjoys reading poorly written reports that are difficult to understand.
+ 4. Keep an eye on your report as long as it is open, your involvement might be requested at a later moment.
+ 5. Keep the title short and descriptive. The title is not the place to write down a full description of the issue.
+ 6. When deciding to leave out information in a field, leave it blank and empty. Avoid writing things such as `n/a` for empty fields.
- type: textarea
- id: what-happened
+ id: bug-description
attributes:
- label: Please describe your bug
- description: Also tell us, what did you expect to happen?
+ label: Description of the bug
+ description: Please provide a detailed description on the bug you encountered, in a readable and comprehensible way.
placeholder: |
- The more information that you are able to provide, the better. Did you do anything before this happened? Did you upgrade or change anything? Any screenshots or logs you can provide will be helpful.
- If you are using an old release of Jellyfin, please also explain why.
+ After upgrading to version x.y.z of Jellyfin, the "login disclaimer" is showing incorrect text. It appears to me that it is appending the server name to the end of the login disclaimer, and showing that to a user. It might be a regression from pull request x. I have tried rebooting my host as well as my container multiple times. I tested this functionality on different clients, and it happens to all the tested clients (client x, y, z), that support the login disclaimer functionality. This makes me believe it is a server side issue.
validations:
required: true
- type: textarea
id: repro-steps
attributes:
- label: Reproduction Steps
+ label: Reproduction steps
+ description: Reproduction steps should be complete and self-contained. Anyone can reproduce this issue by following these steps. Furthermore, the steps should be clear and easy to follow.
+ placeholder: |
+ 1. Sign in on the Jellyfin web client, with an admin account, using a browser of your choice.
+ 2. Navigate to the dashboard.
+ 3. Select "general".
+ 4. Change the login disclaimer to something like "I am a cool disclaimer!"
+ 5. Save the settings.
+ 6. Sign out.
+ 7. Make sure you are on the sign in screen. Otherwise, navigate to the sign in screen manually.
+ validations:
+ required: true
+ - type: textarea
+ id: actual-behavior
+ attributes:
+ label: What is the current _bug_ behavior?
+ description: Write down the incorrect behavior that currently happens after following the reproduction steps.
+ placeholder: |
+ The login disclaimer on the sign in screen has the server name appended to the text. The text shown is: "I am a cool disclaimer!jellyfinserver".
+ validations:
+ required: true
+ - type: textarea
+ id: expected-behavior
+ attributes:
+ label: What is the expected _correct_ behavior?
+ description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps.
placeholder: |
- 1. In this environment...
- 2. With this config...
- 3. Run '...'
- 4. See error...
+ The login disclaimer on the sign in screen should only show the configured text. The text that should be shown is: "I am a cool disclaimer!".
validations:
required: true
- type: dropdown
id: version
attributes:
- label: Jellyfin Version
- description: What version of Jellyfin are you running?
+ label: Jellyfin Server version
+ description: What version of Jellyfin are you using?
options:
- - 10.8.13
- - 10.8.12
- - 10.8.11 or older (please specify)
- - Unstable (master branch)
+ - 10.9.7
+ - Master
+ - Unstable
+ - Older*
validations:
required: true
- type: input
- id: version-other
+ id: version-master
+ attributes:
+ label: "Specify commit id"
+ description: Fill in this field in case the option 'master' is selected. Provide the commit id it was built on.
+ placeholder: |
+ 610e56baafc3011e1bfa043bdabb567bda0c2ab0
+ - type: input
+ id: version-unstable
+ attributes:
+ label: "Specify unstable release number"
+ description: Fill in this field in case the option 'unstable' is selected. Provide the unstable release number.
+ placeholder: |
+ 2024050906
+ - type: input
+ id: version-older
attributes:
- label: "if other:"
- placeholder: Other
+ label: "Specify version number"
+ description: Fill in this field in case the option 'older' is selected. Provide the version number.
+ placeholder: |
+ x.y.z
+ - type: input
+ id: build-version
+ attributes:
+ label: "Specify the build version"
+ description: Please provide the build version that is shown in the dashboard.
+ validations:
+ required: true
- type: textarea
+ id: environment-information
attributes:
label: Environment
description: |
+ Accurately fill in as much environment details as possible. If a certain environment field is not shown in the template below, but you consider useful information, please include it.
Examples:
- **OS**: [e.g. Debian 11, Windows 10]
- **Linux Kernel**: [e.g. none, 5.15, 6.1, etc.]
@@ -87,21 +160,22 @@ body:
validations:
required: true
- type: markdown
+ id: general-information-logs
attributes:
value: |
- When providing logs, please keep the following things in mind.
- 1. **DO NOT** use external paste services.
+ When providing logs, please keep the following things in mind:
+ 1. **DO NOT** use external paste services. If logs are too large to paste into the field, upload them as text files.
2. Please provide complete logs.
- - For server logs, include everything you think is important plus *10 lines before and after*
+ - For server logs, ensure to capture all relevant information, encompassing both the events leading up to and following the occurrence of the issue. Typically, providing 10 *lines preceding and succeeding* the problem should be adequate.
- For ffmpeg logs, please provide the entire file unmodified.
- 3. Please do not run logs through any translation program. Especially beware if your browser translates pages by default.
+ 3. Please do not run logs through any translation program. We exclusively accept raw, untranslated logs. Particularly exercise caution if your browser automatically translates pages by default.
+ - Do not forget to censor out personal information such as public IP addresses.
4. Please do not include logs as screenshots, with the only exception being client logs in browsers.
- type: textarea
- id: logs
+ id: jellyfin-logs
attributes:
label: Jellyfin logs
description: Please copy and paste any relevant log output. This can be found in Dashboard > Logs.
- placeholder: For playback issues, browser/client and FFmpeg logs may be more useful.
render: shell
validations:
required: true
@@ -109,24 +183,20 @@ body:
id: ffmpeg-logs
attributes:
label: FFmpeg logs
- description: Please copy and paste recent FFmpeg log output. This can be found in Dashboard > Logs > FFmpeg*.log.
- placeholder: This field is mandatory for debugging hardware transcoding issues. It's important to include the specific codec details. If no FFmpeg logs appear, the file was Direct Played and did not use FFmpeg.
+ description: Relevant FFmpeg log output. This can be found in Dashboard > Logs > FFmpeg*.log. This field is considered mandatory for transcoding related issues. It's also important to include the specific codec details.
render: shell
- type: textarea
- id: browserlogs
+ id: browser-logs
attributes:
- label: Please attach any browser or client logs here
- placeholder: Access browser logs by using the F12 to bring up the console. Screenshots are typically easier to read than raw logs. For clients such as Android or iOS, please see our documentation.
+ label: Client / Browser logs
+ description: Access browser logs by using the F12 to bring up the console. Screenshots are typically easier to read than raw logs. For clients such as Android or iOS, please see our documentation.
- type: textarea
id: screenshots
attributes:
- label: Please attach any screenshots here
- placeholder: Images can be pasted directly into the textbox and will be hosted by github.
- - type: checkboxes
- id: terms
+ label: Relevant screenshots or videos
+ description: Attach relevant screenshots or videos related to this report.
+ - type: textarea
+ id: additional-information
attributes:
- label: Code of Conduct
- description: By submitting this issue, you agree to follow our [Code of Conduct](https://jellyfin.org/docs/general/community-standards.html#code-of-conduct)
- options:
- - label: I agree to follow this project's Code of Conduct
- required: true
+ label: Additional information
+ description: Any additional information that might be useful to this issue.
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 7b76e47f5..c6ea1d7ca 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@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Setup .NET
- uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
+ uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
with:
dotnet-version: '8.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
+ uses: github/codeql-action/init@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
+ uses: github/codeql-action/autobuild@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
+ uses: github/codeql-action/analyze@4fa2a7953630fd2f3fb380f21be14ede0169dd4f # v3.25.12
diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml
index 0864299f7..54a061556 100644
--- a/.github/workflows/ci-openapi.yml
+++ b/.github/workflows/ci-openapi.yml
@@ -3,6 +3,8 @@ on:
push:
branches:
- master
+ tags:
+ - 'v*'
pull_request_target:
permissions: {}
@@ -14,18 +16,18 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
+ uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
with:
dotnet-version: '8.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@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
+ uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
with:
name: openapi-head
retention-days: 14
@@ -39,7 +41,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -53,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@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
+ uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
with:
dotnet-version: '8.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@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
+ uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
with:
name: openapi-base
retention-days: 14
@@ -78,12 +80,12 @@ jobs:
- openapi-base
steps:
- name: Download openapi-head
- uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
+ uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
- uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
+ uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: openapi-base
path: openapi-base
@@ -99,13 +101,26 @@ jobs:
- id: read-diff
name: Read openapi-diff output
run: |
+ # Read and fix markdown
body=$(cat openapi-changes.md)
- body="${body//'%'/'%25'}"
- body="${body//$'\n'/'%0A'}"
- body="${body//$'\r'/'%0D'}"
- echo ::set-output name=body::$body
+ # Write to workflow summary
+ echo "$body" >> $GITHUB_STEP_SUMMARY
+ # Set ApiChanged var
+ if [ "$body" != '' ]; then
+ echo "ApiChanged=1" >> "$GITHUB_OUTPUT"
+ else
+ echo "ApiChanged=0" >> "$GITHUB_OUTPUT"
+ fi
+ # Add header/footer for diff comment
+ echo '<!--openapi-diff-workflow-comment-->' > openapi-changes-reply.md
+ echo "<details>" >> openapi-changes-reply.md
+ echo "<summary>Changes in OpenAPI specification found. Expand to see details.</summary>" >> openapi-changes-reply.md
+ echo "" >> openapi-changes-reply.md
+ echo "$body" >> openapi-changes-reply.md
+ echo "" >> openapi-changes-reply.md
+ echo "</details>" >> openapi-changes-reply.md
- name: Find difference comment
- uses: peter-evans/find-comment@d5fe37641ad8451bdd80312415672ba26c86575e # v3.0.0
+ uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -113,22 +128,15 @@ jobs:
body-includes: openapi-diff-workflow-comment
- name: Reply or edit difference comment (changed)
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
- if: ${{ steps.read-diff.outputs.body != '' }}
+ if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
- body: |
- <!--openapi-diff-workflow-comment-->
- <details>
- <summary>Changes in OpenAPI specification found. Expand to see details.</summary>
-
- ${{ steps.read-diff.outputs.body }}
-
- </details>
+ body-path: openapi-changes-reply.md
- name: Edit difference comment (unchanged)
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
- if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
+ if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }}
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
@@ -138,11 +146,9 @@ jobs:
No changes to OpenAPI specification found. See history of this comment for previous changes.
- publish:
+ publish-unstable:
name: OpenAPI - Publish Unstable Spec
- if: |
- github.event_name != 'pull_request_target' &&
- contains(github.repository_owner, 'jellyfin')
+ if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
runs-on: ubuntu-latest
needs:
- openapi-head
@@ -152,7 +158,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
- name: Download openapi-head
- uses: actions/download-artifact@c850b930e6ba138125429b7e5c93fc707a7f8427 # v4.1.4
+ uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: openapi-head
path: openapi-head
@@ -174,6 +180,12 @@ jobs:
debug: false
script_stop: false
script: |
+ if ! test -d /run/workflows; then
+ sudo mkdir -p /run/workflows
+ sudo chown ${{ secrets.REPO_USER }} /run/workflows
+ fi
+ (
+ flock -x -w 300 200 || exit 1
TGT_DIR="/srv/repository/main/openapi"
LAST_SPEC="$( ls -lt ${TGT_DIR}/unstable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
@@ -187,10 +199,73 @@ jobs:
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
# Move current jellyfin-openapi-unstable.json symlink to jellyfin-openapi-unstable_previous.json
sudo mv ${TGT_DIR}/jellyfin-openapi-unstable.json ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
- # Create new jellyfin-openapi-stable.json symlink
+ # Create new jellyfin-openapi-unstable.json symlink
sudo ln -s unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-unstable.json
- # Check that the previous openapi spec is correct
+ # Check that the previous openapi unstable spec link is correct
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-unstable_previous.json )" != "unstable/${LAST_SPEC}" ]]; then
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
sudo ln -s unstable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
fi
+ ) 200>/run/workflows/openapi-unstable.lock
+
+ publish-stable:
+ name: OpenAPI - Publish Stable Spec
+ if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
+ runs-on: ubuntu-latest
+ needs:
+ - openapi-head
+ steps:
+ - name: Set version number
+ id: version
+ run: |-
+ echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
+ - name: Download openapi-head
+ uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
+ with:
+ name: openapi-head
+ path: openapi-head
+ - name: Upload openapi.json (stable) to repository server
+ uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
+ with:
+ host: "${{ secrets.REPO_HOST }}"
+ username: "${{ secrets.REPO_USER }}"
+ key: "${{ secrets.REPO_KEY }}"
+ source: openapi-head/openapi.json
+ strip_components: 1
+ target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
+ - name: Move openapi.json (stable) into place
+ uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
+ with:
+ host: "${{ secrets.REPO_HOST }}"
+ username: "${{ secrets.REPO_USER }}"
+ key: "${{ secrets.REPO_KEY }}"
+ debug: false
+ script_stop: false
+ script: |
+ if ! test -d /run/workflows; then
+ sudo mkdir -p /run/workflows
+ sudo chown ${{ secrets.REPO_USER }} /run/workflows
+ fi
+ (
+ flock -x -w 300 200 || exit 1
+ TGT_DIR="/srv/repository/main/openapi"
+ LAST_SPEC="$( ls -lt ${TGT_DIR}/stable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
+ # If new and previous spec don't differ (diff retcode 0), remove incoming and finish
+ if diff /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/${LAST_SPEC} &>/dev/null; then
+ rm -r /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
+ exit 0
+ fi
+ # Move new spec into place
+ sudo mv /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
+ # Delete previous jellyfin-openapi-stable_previous.json
+ sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
+ # Move current jellyfin-openapi-stable.json symlink to jellyfin-openapi-stable_previous.json
+ sudo mv ${TGT_DIR}/jellyfin-openapi-stable.json ${TGT_DIR}/jellyfin-openapi-stable_previous.json
+ # Create new jellyfin-openapi-stable.json symlink
+ sudo ln -s stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-stable.json
+ # Check that the previous openapi stable spec link is correct
+ if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-stable_previous.json )" != "stable/${LAST_SPEC}" ]]; then
+ sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
+ sudo ln -s stable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-stable_previous.json
+ fi
+ ) 200>/run/workflows/openapi-stable.lock
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 612e8c751..91c2be87b 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -19,9 +19,9 @@ jobs:
runs-on: "${{ matrix.os }}"
steps:
- - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- - uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
+ - uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
with:
dotnet-version: ${{ env.SDK_VERSION }}
@@ -34,7 +34,7 @@ jobs:
--verbosity minimal
- name: Merge code coverage results
- uses: danielpalme/ReportGenerator-GitHub-Action@3e39bd1b454c2bac14560547e4394f9317672705 # 5.2.4
+ uses: danielpalme/ReportGenerator-GitHub-Action@5808021ec4deecb0ab3da051d49b4ce65fcc20af # 5.3.8
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml
index d78f11166..b79185855 100644
--- a/.github/workflows/commands.yml
+++ b/.github/workflows/commands.yml
@@ -24,7 +24,7 @@ jobs:
reactions: '+1'
- name: Checkout the latest code
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -51,7 +51,7 @@ jobs:
reactions: eyes
- name: Checkout the latest code
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -128,11 +128,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
+ uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1
with:
python-version: '3.12'
cache: 'pip'
diff --git a/.github/workflows/issue-template-check.yml b/.github/workflows/issue-template-check.yml
index e53234641..6172455c2 100644
--- a/.github/workflows/issue-template-check.yml
+++ b/.github/workflows/issue-template-check.yml
@@ -10,11 +10,11 @@ jobs:
issues: write
steps:
- name: pull in script
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
- uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0
+ uses: actions/setup-python@39cd14951b08e74b54015e9e001cdefcf80e669f # v5.1.1
with:
python-version: '3.12'
cache: 'pip'
diff --git a/.github/workflows/pull-request-conflict.yml b/.github/workflows/pull-request-conflict.yml
index 05517bb03..5d342b7f8 100644
--- a/.github/workflows/pull-request-conflict.yml
+++ b/.github/workflows/pull-request-conflict.yml
@@ -15,7 +15,7 @@ jobs:
if: ${{ github.repository == 'jellyfin/jellyfin' }}
steps:
- name: Apply label
- uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0
+ uses: eps1lon/actions-label-merge-conflict@1b1b1fcde06a9b3d089f3464c96417961dde1168 # v3.0.2
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
with:
dirtyLabel: 'merge conflict'
diff --git a/.github/workflows/release-bump-version.yaml b/.github/workflows/release-bump-version.yaml
index e0383afd2..575f2d756 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@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+ uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
with:
ref: ${{ env.TAG_BRANCH }}
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 8550222a1..edbc846d6 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -183,6 +183,9 @@
- [btopherjohnson](https://github.com/btopherjohnson)
- [GeorgeH005](https://github.com/GeorgeH005)
- [Vedant](https://github.com/viktory36/)
+ - [NotSaifA](https://github.com/NotSaifA)
+ - [HonestlyWhoKnows](https://github.com/honestlywhoknows)
+ - [ItsAllAboutTheCode](https://github.com/ItsAllAboutTheCode)
# Emby Contributors
@@ -255,3 +258,4 @@
- [JPUC1143](https://github.com/Jpuc1143/)
- [0x25CBFC4F](https://github.com/0x25CBFC4F)
- [Robert Lützner](https://github.com/rluetzner)
+ - [Nathan McCrina](https://github.com/nfmccrina)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ac0a523c5..825301bfc 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,7 +4,7 @@
</PropertyGroup>
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
<ItemGroup Label="Package Dependencies">
- <PackageVersion Include="AsyncKeyedLock" Version="6.3.4" />
+ <PackageVersion Include="AsyncKeyedLock" Version="6.4.2" />
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
@@ -13,43 +13,42 @@
<PackageVersion Include="BlurHashSharp" Version="1.3.2" />
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
- <PackageVersion Include="Diacritics" Version="3.3.27" />
+ <PackageVersion Include="Diacritics" Version="3.3.29" />
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
- <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.2.3" />
+ <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.5.0" />
<PackageVersion Include="FsCheck.Xunit" Version="2.16.6" />
- <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.1" />
+ <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.2" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
- <PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" />
+ <PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
- <PackageVersion Include="libse" Version="3.6.13" />
+ <PackageVersion Include="libse" Version="4.0.5" />
<PackageVersion Include="LrcParser" Version="2023.524.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
- <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.3" />
- <PackageVersion Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
- <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.3" />
+ <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.7" />
+ <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
- <PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.3" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.3" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.3" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.3" />
+ <PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.7" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.7" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
- <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
+ <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.3" />
- <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.3" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.7" />
+ <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
- <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
+ <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageVersion Include="MimeTypes" Version="2.4.0" />
<PackageVersion Include="Mono.Nat" Version="3.0.4" />
<PackageVersion Include="Moq" Version="4.18.4" />
@@ -60,33 +59,33 @@
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
- <PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" />
- <PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.0" />
- <PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
- <PackageVersion Include="Serilog.Sinks.Console" Version="5.0.1" />
- <PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
+ <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
+ <PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.2" />
+ <PackageVersion Include="Serilog.Sinks.Async" Version="2.0.0" />
+ <PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
+ <PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpFuzz" Version="2.1.1" />
- <PackageVersion Include="SkiaSharp" Version="2.88.7" />
- <PackageVersion Include="SkiaSharp.HarfBuzz" Version="2.88.7" />
- <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.7" />
+ <PackageVersion Include="SkiaSharp" Version="2.88.8" />
+ <PackageVersion Include="SkiaSharp.HarfBuzz" Version="2.88.8" />
+ <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.8" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
- <PackageVersion Include="Svg.Skia" Version="1.0.0.17" />
+ <PackageVersion Include="Svg.Skia" Version="1.0.0.18" />
<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="8.0.0" />
- <PackageVersion Include="System.Text.Json" Version="8.0.3" />
- <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="8.0.0" />
+ <PackageVersion Include="System.Text.Json" Version="8.0.4" />
+ <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="8.0.1" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="TMDbLib" Version="2.2.0" />
<PackageVersion Include="UTF.Unknown" Version="2.5.1" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
- <PackageVersion Include="xunit.runner.visualstudio" Version="2.5.7" />
+ <PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" />
- <PackageVersion Include="xunit" Version="2.7.0" />
+ <PackageVersion Include="xunit" Version="2.9.0" />
</ItemGroup>
</Project> \ No newline at end of file
diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs
index 4bd226d95..333d237a2 100644
--- a/Emby.Naming/Common/NamingOptions.cs
+++ b/Emby.Naming/Common/NamingOptions.cs
@@ -540,6 +540,12 @@ namespace Emby.Naming.Common
new ExtraRule(
ExtraType.Unknown,
ExtraRuleType.DirectoryName,
+ "extra",
+ MediaType.Video),
+
+ new ExtraRule(
+ ExtraType.Unknown,
+ ExtraRuleType.DirectoryName,
"other",
MediaType.Video),
diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj
index 97015efd0..7eb131575 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.9.0</VersionPrefix>
+ <VersionPrefix>10.10.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 9d54533c2..7a01b02f3 100644
--- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs
+++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs
@@ -107,7 +107,7 @@ namespace Emby.Naming.ExternalFiles
pathInfo.Language = culture.ThreeLetterISOLanguageName;
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
}
- else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase)))
+ else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Equals(s, StringComparison.OrdinalIgnoreCase)))
{
pathInfo.IsHearingImpaired = true;
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
diff --git a/Emby.Naming/TV/TvParserHelpers.cs b/Emby.Naming/TV/TvParserHelpers.cs
new file mode 100644
index 000000000..029917858
--- /dev/null
+++ b/Emby.Naming/TV/TvParserHelpers.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Linq;
+using MediaBrowser.Model.Entities;
+
+namespace Emby.Naming.TV;
+
+/// <summary>
+/// Helper class for TV metadata parsing.
+/// </summary>
+public static class TvParserHelpers
+{
+ private static readonly string[] _continuingState = ["Pilot", "Returning Series", "Returning"];
+ private static readonly string[] _endedState = ["Cancelled", "Canceled"];
+
+ /// <summary>
+ /// Tries to parse a string into <see cref="SeriesStatus"/>.
+ /// </summary>
+ /// <param name="status">The status string.</param>
+ /// <param name="enumValue">The <see cref="SeriesStatus"/>.</param>
+ /// <returns>Returns true if parsing was successful.</returns>
+ public static bool TryParseSeriesStatus(string status, out SeriesStatus? enumValue)
+ {
+ if (Enum.TryParse(status, true, out SeriesStatus seriesStatus))
+ {
+ enumValue = seriesStatus;
+ return true;
+ }
+
+ if (_continuingState.Contains(status, StringComparer.OrdinalIgnoreCase))
+ {
+ enumValue = SeriesStatus.Continuing;
+ return true;
+ }
+
+ if (_endedState.Contains(status, StringComparer.OrdinalIgnoreCase))
+ {
+ enumValue = SeriesStatus.Ended;
+ return true;
+ }
+
+ enumValue = null;
+ return false;
+ }
+}
diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
index 39524be1d..dc845b2d7 100644
--- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
+++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs
@@ -104,6 +104,6 @@ namespace Emby.Server.Implementations.AppBase
/// Gets the folder path to the temp directory within the cache folder.
/// </summary>
/// <value>The temp directory.</value>
- public string TempDirectory => Path.Combine(CachePath, "temp");
+ public string TempDirectory => Path.Join(Path.GetTempPath(), "jellyfin");
}
}
diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs
index a2f38c8c2..9e98d5ce0 100644
--- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs
+++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs
@@ -127,15 +127,11 @@ namespace Emby.Server.Implementations.AppBase
if (_configurationFactories is null)
{
- _configurationFactories = new[] { factory };
+ _configurationFactories = [factory];
}
else
{
- var oldLen = _configurationFactories.Length;
- var arr = new IConfigurationFactory[oldLen + 1];
- _configurationFactories.CopyTo(arr, 0);
- arr[oldLen] = factory;
- _configurationFactories = arr;
+ _configurationFactories = [.._configurationFactories, factory];
}
_configurationStores = _configurationFactories
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index acabbb059..5bf9c4fc2 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -109,13 +109,13 @@ namespace Emby.Server.Implementations
/// <summary>
/// The disposable parts.
/// </summary>
- private readonly ConcurrentDictionary<IDisposable, byte> _disposableParts = new();
+ private readonly ConcurrentBag<IDisposable> _disposableParts = new();
private readonly DeviceId _deviceId;
private readonly IConfiguration _startupConfig;
private readonly IXmlSerializer _xmlSerializer;
private readonly IStartupOptions _startupOptions;
- private readonly IPluginManager _pluginManager;
+ private readonly PluginManager _pluginManager;
private List<Type> _creatingInstances;
@@ -161,7 +161,7 @@ namespace Emby.Server.Implementations
ApplicationPaths.PluginsPath,
ApplicationVersion);
- _disposableParts.TryAdd((PluginManager)_pluginManager, byte.MinValue);
+ _disposableParts.Add(_pluginManager);
}
/// <summary>
@@ -360,7 +360,7 @@ namespace Emby.Server.Implementations
{
foreach (var part in parts.OfType<IDisposable>())
{
- _disposableParts.TryAdd(part, byte.MinValue);
+ _disposableParts.Add(part);
}
}
@@ -381,7 +381,7 @@ namespace Emby.Server.Implementations
{
foreach (var part in parts.OfType<IDisposable>())
{
- _disposableParts.TryAdd(part, byte.MinValue);
+ _disposableParts.Add(part);
}
}
@@ -422,7 +422,7 @@ namespace Emby.Server.Implementations
// Initialize runtime stat collection
if (ConfigurationManager.Configuration.EnableMetrics)
{
- DotNetRuntimeStatsBuilder.Default().StartCollecting();
+ _disposableParts.Add(DotNetRuntimeStatsBuilder.Default().StartCollecting());
}
var networkConfiguration = ConfigurationManager.GetNetworkConfiguration();
@@ -457,7 +457,7 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<IServerConfigurationManager>(ConfigurationManager);
serviceCollection.AddSingleton<IConfigurationManager>(ConfigurationManager);
serviceCollection.AddSingleton<IApplicationHost>(this);
- serviceCollection.AddSingleton(_pluginManager);
+ serviceCollection.AddSingleton<IPluginManager>(_pluginManager);
serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths);
serviceCollection.AddSingleton<IFileSystem, ManagedFileSystem>();
@@ -664,7 +664,8 @@ namespace Emby.Server.Implementations
GetExports<IMetadataService>(),
GetExports<IMetadataProvider>(),
GetExports<IMetadataSaver>(),
- GetExports<IExternalId>());
+ GetExports<IExternalId>(),
+ GetExports<IExternalUrlProvider>());
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
}
@@ -965,7 +966,7 @@ namespace Emby.Server.Implementations
Logger.LogInformation("Disposing {Type}", type.Name);
- foreach (var (part, _) in _disposableParts)
+ foreach (var part in _disposableParts.ToArray())
{
var partType = part.GetType();
if (partType == type)
diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs
index b34d0f21e..e414792ba 100644
--- a/Emby.Server.Implementations/Collections/CollectionManager.cs
+++ b/Emby.Server.Implementations/Collections/CollectionManager.cs
@@ -102,7 +102,7 @@ namespace Emby.Server.Implementations.Collections
var name = _localizationManager.GetLocalizedString("Collections");
- await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.BoxSets, libraryOptions, true).ConfigureAwait(false);
+ await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
return FindFolders(path).First();
}
diff --git a/Emby.Server.Implementations/ConfigurationOptions.cs b/Emby.Server.Implementations/ConfigurationOptions.cs
index f0c267627..c06cd8510 100644
--- a/Emby.Server.Implementations/ConfigurationOptions.cs
+++ b/Emby.Server.Implementations/ConfigurationOptions.cs
@@ -19,7 +19,8 @@ namespace Emby.Server.Implementations
{ FfmpegAnalyzeDurationKey, "200M" },
{ PlaylistsAllowDuplicatesKey, bool.FalseString },
{ BindToUnixSocketKey, bool.FalseString },
- { SqliteCacheSizeKey, "20000" }
+ { SqliteCacheSizeKey, "20000" },
+ { SqliteDisableSecondLevelCacheKey, bool.FalseString }
};
}
}
diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs
index bf079d90c..5291999dc 100644
--- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs
+++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
+using System.Threading;
using Jellyfin.Extensions;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
@@ -13,6 +14,8 @@ namespace Emby.Server.Implementations.Data
public abstract class BaseSqliteRepository : IDisposable
{
private bool _disposed = false;
+ private SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1);
+ private SqliteConnection _writeConnection;
/// <summary>
/// Initializes a new instance of the <see cref="BaseSqliteRepository"/> class.
@@ -29,17 +32,6 @@ namespace Emby.Server.Implementations.Data
protected string DbFilePath { get; set; }
/// <summary>
- /// Gets or sets the number of write connections to create.
- /// </summary>
- /// <value>Path to the DB file.</value>
- protected int WriteConnectionsCount { get; set; } = 1;
-
- /// <summary>
- /// Gets or sets the number of read connections to create.
- /// </summary>
- protected int ReadConnectionsCount { get; set; } = 1;
-
- /// <summary>
/// Gets the logger.
/// </summary>
/// <value>The logger.</value>
@@ -98,9 +90,55 @@ namespace Emby.Server.Implementations.Data
}
}
- protected SqliteConnection GetConnection()
+ protected ManagedConnection GetConnection(bool readOnly = false)
{
- var connection = new SqliteConnection($"Filename={DbFilePath}");
+ if (!readOnly)
+ {
+ _writeLock.Wait();
+ if (_writeConnection is not null)
+ {
+ return new ManagedConnection(_writeConnection, _writeLock);
+ }
+
+ var writeConnection = new SqliteConnection($"Filename={DbFilePath};Pooling=False");
+ writeConnection.Open();
+
+ if (CacheSize.HasValue)
+ {
+ writeConnection.Execute("PRAGMA cache_size=" + CacheSize.Value);
+ }
+
+ if (!string.IsNullOrWhiteSpace(LockingMode))
+ {
+ writeConnection.Execute("PRAGMA locking_mode=" + LockingMode);
+ }
+
+ if (!string.IsNullOrWhiteSpace(JournalMode))
+ {
+ writeConnection.Execute("PRAGMA journal_mode=" + JournalMode);
+ }
+
+ if (JournalSizeLimit.HasValue)
+ {
+ writeConnection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value);
+ }
+
+ if (Synchronous.HasValue)
+ {
+ writeConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value);
+ }
+
+ if (PageSize.HasValue)
+ {
+ writeConnection.Execute("PRAGMA page_size=" + PageSize.Value);
+ }
+
+ writeConnection.Execute("PRAGMA temp_store=" + (int)TempStore);
+
+ return new ManagedConnection(_writeConnection = writeConnection, _writeLock);
+ }
+
+ var connection = new SqliteConnection($"Filename={DbFilePath};Mode=ReadOnly");
connection.Open();
if (CacheSize.HasValue)
@@ -135,17 +173,17 @@ namespace Emby.Server.Implementations.Data
connection.Execute("PRAGMA temp_store=" + (int)TempStore);
- return connection;
+ return new ManagedConnection(connection, null);
}
- public SqliteCommand PrepareStatement(SqliteConnection connection, string sql)
+ public SqliteCommand PrepareStatement(ManagedConnection connection, string sql)
{
var command = connection.CreateCommand();
command.CommandText = sql;
return command;
}
- protected bool TableExists(SqliteConnection connection, string name)
+ protected bool TableExists(ManagedConnection connection, string name)
{
using var statement = PrepareStatement(connection, "select DISTINCT tbl_name from sqlite_master");
foreach (var row in statement.ExecuteQuery())
@@ -159,7 +197,7 @@ namespace Emby.Server.Implementations.Data
return false;
}
- protected List<string> GetColumnNames(SqliteConnection connection, string table)
+ protected List<string> GetColumnNames(ManagedConnection connection, string table)
{
var columnNames = new List<string>();
@@ -174,7 +212,7 @@ namespace Emby.Server.Implementations.Data
return columnNames;
}
- protected void AddColumn(SqliteConnection connection, string table, string columnName, string type, List<string> existingColumnNames)
+ protected void AddColumn(ManagedConnection connection, string table, string columnName, string type, List<string> existingColumnNames)
{
if (existingColumnNames.Contains(columnName, StringComparison.OrdinalIgnoreCase))
{
@@ -186,10 +224,7 @@ namespace Emby.Server.Implementations.Data
protected void CheckDisposed()
{
- if (_disposed)
- {
- throw new ObjectDisposedException(GetType().Name, "Object has been disposed and cannot be accessed.");
- }
+ ObjectDisposedException.ThrowIf(_disposed, this);
}
/// <inheritdoc />
@@ -210,6 +245,24 @@ namespace Emby.Server.Implementations.Data
return;
}
+ if (dispose)
+ {
+ _writeLock.Wait();
+ try
+ {
+ _writeConnection.Dispose();
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+
+ _writeLock.Dispose();
+ }
+
+ _writeConnection = null;
+ _writeLock = null;
+
_disposed = true;
}
}
diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs
new file mode 100644
index 000000000..860950b30
--- /dev/null
+++ b/Emby.Server.Implementations/Data/ManagedConnection.cs
@@ -0,0 +1,62 @@
+#pragma warning disable CS1591
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Microsoft.Data.Sqlite;
+
+namespace Emby.Server.Implementations.Data;
+
+public sealed class ManagedConnection : IDisposable
+{
+ private readonly SemaphoreSlim? _writeLock;
+
+ private SqliteConnection _db;
+
+ private bool _disposed = false;
+
+ public ManagedConnection(SqliteConnection db, SemaphoreSlim? writeLock)
+ {
+ _db = db;
+ _writeLock = writeLock;
+ }
+
+ public SqliteTransaction BeginTransaction()
+ => _db.BeginTransaction();
+
+ public SqliteCommand CreateCommand()
+ => _db.CreateCommand();
+
+ public void Execute(string commandText)
+ => _db.Execute(commandText);
+
+ public SqliteCommand PrepareStatement(string sql)
+ => _db.PrepareStatement(sql);
+
+ public IEnumerable<SqliteDataReader> Query(string commandText)
+ => _db.Query(commandText);
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (_writeLock is null)
+ {
+ // Read connections are managed with an internal pool
+ _db.Dispose();
+ }
+ else
+ {
+ // Write lock is managed by BaseSqliteRepository
+ // Don't dispose here
+ _writeLock.Release();
+ }
+
+ _db = null!;
+
+ _disposed = true;
+ }
+}
diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
index f1e60915d..60f5ee47a 100644
--- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs
+++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
@@ -49,8 +49,8 @@ namespace Emby.Server.Implementations.Data
private const string SaveItemCommandText =
@"replace into TypedBaseItems
- (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,LUFS,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId)
- values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@LUFS,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)";
+ (guid,type,data,Path,StartDate,EndDate,ChannelId,IsMovie,IsSeries,EpisodeTitle,IsRepeat,CommunityRating,CustomRating,IndexNumber,IsLocked,Name,OfficialRating,MediaType,Overview,ParentIndexNumber,PremiereDate,ProductionYear,ParentId,Genres,InheritedParentalRatingValue,SortName,ForcedSortName,RunTimeTicks,Size,DateCreated,DateModified,PreferredMetadataLanguage,PreferredMetadataCountryCode,Width,Height,DateLastRefreshed,DateLastSaved,IsInMixedFolder,LockedFields,Studios,Audio,ExternalServiceId,Tags,IsFolder,UnratedType,TopParentId,TrailerTypes,CriticRating,CleanName,PresentationUniqueKey,OriginalTitle,PrimaryVersionId,DateLastMediaAdded,Album,LUFS,NormalizationGain,IsVirtualItem,SeriesName,UserDataKey,SeasonName,SeasonId,SeriesId,ExternalSeriesId,Tagline,ProviderIds,Images,ProductionLocations,ExtraIds,TotalBitrate,ExtraType,Artists,AlbumArtists,ExternalId,SeriesPresentationUniqueKey,ShowId,OwnerId)
+ values (@guid,@type,@data,@Path,@StartDate,@EndDate,@ChannelId,@IsMovie,@IsSeries,@EpisodeTitle,@IsRepeat,@CommunityRating,@CustomRating,@IndexNumber,@IsLocked,@Name,@OfficialRating,@MediaType,@Overview,@ParentIndexNumber,@PremiereDate,@ProductionYear,@ParentId,@Genres,@InheritedParentalRatingValue,@SortName,@ForcedSortName,@RunTimeTicks,@Size,@DateCreated,@DateModified,@PreferredMetadataLanguage,@PreferredMetadataCountryCode,@Width,@Height,@DateLastRefreshed,@DateLastSaved,@IsInMixedFolder,@LockedFields,@Studios,@Audio,@ExternalServiceId,@Tags,@IsFolder,@UnratedType,@TopParentId,@TrailerTypes,@CriticRating,@CleanName,@PresentationUniqueKey,@OriginalTitle,@PrimaryVersionId,@DateLastMediaAdded,@Album,@LUFS,@NormalizationGain,@IsVirtualItem,@SeriesName,@UserDataKey,@SeasonName,@SeasonId,@SeriesId,@ExternalSeriesId,@Tagline,@ProviderIds,@Images,@ProductionLocations,@ExtraIds,@TotalBitrate,@ExtraType,@Artists,@AlbumArtists,@ExternalId,@SeriesPresentationUniqueKey,@ShowId,@OwnerId)";
private readonly IServerConfigurationManager _config;
private readonly IServerApplicationHost _appHost;
@@ -111,6 +111,7 @@ namespace Emby.Server.Implementations.Data
"DateLastMediaAdded",
"Album",
"LUFS",
+ "NormalizationGain",
"CriticRating",
"IsVirtualItem",
"SeriesName",
@@ -327,7 +328,6 @@ namespace Emby.Server.Implementations.Data
DbFilePath = Path.Combine(_config.ApplicationPaths.DataPath, "library.db");
CacheSize = configuration.GetSqliteCacheSize();
- ReadConnectionsCount = Environment.ProcessorCount * 2;
}
/// <inheritdoc />
@@ -479,6 +479,7 @@ namespace Emby.Server.Implementations.Data
AddColumn(connection, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "Album", "Text", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "LUFS", "Float", existingColumnNames);
+ AddColumn(connection, "TypedBaseItems", "NormalizationGain", "Float", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "SeriesName", "Text", existingColumnNames);
AddColumn(connection, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames);
@@ -602,7 +603,7 @@ namespace Emby.Server.Implementations.Data
transaction.Commit();
}
- private void SaveItemsInTransaction(SqliteConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples)
+ private void SaveItemsInTransaction(ManagedConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples)
{
using (var saveItemStatement = PrepareStatement(db, SaveItemCommandText))
using (var deleteAncestorsStatement = PrepareStatement(db, "delete from AncestorIds where ItemId=@ItemId"))
@@ -889,6 +890,7 @@ namespace Emby.Server.Implementations.Data
saveItemStatement.TryBind("@Album", item.Album);
saveItemStatement.TryBind("@LUFS", item.LUFS);
+ saveItemStatement.TryBind("@NormalizationGain", item.NormalizationGain);
saveItemStatement.TryBind("@IsVirtualItem", item.IsVirtualItem);
if (item is IHasSeries hasSeriesName)
@@ -1047,9 +1049,10 @@ namespace Emby.Server.Implementations.Data
foreach (var part in value.SpanSplit('|'))
{
var providerDelimiterIndex = part.IndexOf('=');
- if (providerDelimiterIndex != -1 && providerDelimiterIndex == part.LastIndexOf('='))
+ // Don't let empty values through
+ if (providerDelimiterIndex != -1 && part.Length != providerDelimiterIndex + 1)
{
- item.SetProviderId(part.Slice(0, providerDelimiterIndex).ToString(), part.Slice(providerDelimiterIndex + 1).ToString());
+ item.SetProviderId(part[..providerDelimiterIndex].ToString(), part[(providerDelimiterIndex + 1)..].ToString());
}
}
}
@@ -1261,7 +1264,7 @@ namespace Emby.Server.Implementations.Data
CheckDisposed();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery))
{
statement.TryBind("@guid", id);
@@ -1298,16 +1301,15 @@ namespace Emby.Server.Implementations.Data
&& type != typeof(Book)
&& type != typeof(LiveTvProgram)
&& type != typeof(AudioBook)
- && type != typeof(Audio)
&& type != typeof(MusicAlbum);
}
private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query)
{
- return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query));
+ return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query), false);
}
- private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields)
+ private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields, bool skipDeserialization)
{
var typeString = reader.GetString(0);
@@ -1320,7 +1322,7 @@ namespace Emby.Server.Implementations.Data
BaseItem item = null;
- if (TypeRequiresDeserialization(type))
+ if (TypeRequiresDeserialization(type) && !skipDeserialization)
{
try
{
@@ -1675,6 +1677,11 @@ namespace Emby.Server.Implementations.Data
item.LUFS = lUFS;
}
+ if (reader.TryGetSingle(index++, out var normalizationGain))
+ {
+ item.NormalizationGain = normalizationGain;
+ }
+
if (reader.TryGetSingle(index++, out var criticRating))
{
item.CriticRating = criticRating;
@@ -1883,7 +1890,7 @@ namespace Emby.Server.Implementations.Data
CheckDisposed();
var chapters = new List<ChapterInfo>();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc"))
{
statement.TryBind("@ItemId", item.Id);
@@ -1902,7 +1909,7 @@ namespace Emby.Server.Implementations.Data
{
CheckDisposed();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex"))
{
statement.TryBind("@ItemId", item.Id);
@@ -1976,7 +1983,7 @@ namespace Emby.Server.Implementations.Data
transaction.Commit();
}
- private void InsertChapters(Guid idBlob, IReadOnlyList<ChapterInfo> chapters, SqliteConnection db)
+ private void InsertChapters(Guid idBlob, IReadOnlyList<ChapterInfo> chapters, ManagedConnection db)
{
var startIndex = 0;
var limit = 100;
@@ -2318,14 +2325,7 @@ namespace Emby.Server.Implementations.Data
columns.Add(builder.ToString());
- var oldLen = query.ExcludeItemIds.Length;
- var newLen = oldLen + item.ExtraIds.Length + 1;
- var excludeIds = new Guid[newLen];
- query.ExcludeItemIds.CopyTo(excludeIds, 0);
- excludeIds[oldLen] = item.Id;
- item.ExtraIds.CopyTo(excludeIds, oldLen + 1);
-
- query.ExcludeItemIds = excludeIds;
+ query.ExcludeItemIds = [.. query.ExcludeItemIds, item.Id, .. item.ExtraIds];
query.ExcludeProviderIds = item.ProviderIds;
}
@@ -2472,7 +2472,7 @@ namespace Emby.Server.Implementations.Data
var commandText = commandTextBuilder.ToString();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText))
{
if (EnableJoinUserData(query))
@@ -2540,7 +2540,7 @@ namespace Emby.Server.Implementations.Data
var commandText = commandTextBuilder.ToString();
var items = new List<BaseItem>();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText))
{
if (EnableJoinUserData(query))
@@ -2564,7 +2564,7 @@ namespace Emby.Server.Implementations.Data
foreach (var row in statement.ExecuteQuery())
{
- var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields);
+ var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, query.SkipDeserialization);
if (item is not null)
{
items.Add(item);
@@ -2748,7 +2748,7 @@ namespace Emby.Server.Implementations.Data
var list = new List<BaseItem>();
var result = new QueryResult<BaseItem>();
- using var connection = GetConnection();
+ using var connection = GetConnection(true);
using var transaction = connection.BeginTransaction();
if (!isReturningZeroItems)
{
@@ -2776,7 +2776,7 @@ namespace Emby.Server.Implementations.Data
foreach (var row in statement.ExecuteQuery())
{
- var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields);
+ var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false);
if (item is not null)
{
list.Add(item);
@@ -2833,10 +2833,7 @@ namespace Emby.Server.Implementations.Data
prepend.Add((ItemSortBy.Random, SortOrder.Ascending));
}
- var arr = new (ItemSortBy, SortOrder)[prepend.Count + orderBy.Count];
- prepend.CopyTo(arr, 0);
- orderBy.CopyTo(arr, prepend.Count);
- orderBy = query.OrderBy = arr;
+ orderBy = query.OrderBy = [.. prepend, .. orderBy];
}
else if (orderBy.Count == 0)
{
@@ -2933,7 +2930,7 @@ namespace Emby.Server.Implementations.Data
var commandText = commandTextBuilder.ToString();
var list = new List<Guid>();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText))
{
if (EnableJoinUserData(query))
@@ -4197,7 +4194,19 @@ namespace Emby.Server.Implementations.Data
{
int index = 0;
string includedTags = string.Join(',', query.IncludeInheritedTags.Select(_ => paramName + index++));
- whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + includedTags + ")) is not null)");
+ // Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client.
+ // In addtion 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)
+ {
+ whereClauses.Add($"""
+ ((select CleanValue from ItemValues where ItemId=Guid and Type=6 and CleanValue in ({includedTags})) is not null
+ OR (select CleanValue from ItemValues where ItemId=ParentId and Type=6 and CleanValue in ({includedTags})) is not null)
+ """);
+ }
+ else
+ {
+ whereClauses.Add("((select CleanValue from ItemValues where ItemId=Guid and Type=6 and cleanvalue in (" + includedTags + ")) is not null)");
+ }
}
else
{
@@ -4470,7 +4479,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
transaction.Commit();
}
- private void ExecuteWithSingleParam(SqliteConnection db, string query, Guid value)
+ private void ExecuteWithSingleParam(ManagedConnection db, string query, Guid value)
{
using (var statement = PrepareStatement(db, query))
{
@@ -4503,7 +4512,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
}
var list = new List<string>();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText.ToString()))
{
// Run this again to bind the params
@@ -4541,7 +4550,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
}
var list = new List<PersonInfo>();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText.ToString()))
{
// Run this again to bind the params
@@ -4626,7 +4635,7 @@ AND Type = @InternalPersonType)");
return whereClauses;
}
- private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, SqliteConnection db, SqliteCommand deleteAncestorsStatement)
+ private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, ManagedConnection db, SqliteCommand deleteAncestorsStatement)
{
if (itemId.IsEmpty())
{
@@ -4781,7 +4790,7 @@ AND Type = @InternalPersonType)");
var list = new List<string>();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, commandText))
{
foreach (var row in statement.ExecuteQuery())
@@ -4981,8 +4990,8 @@ AND Type = @InternalPersonType)");
var list = new List<(BaseItem, ItemCounts)>();
var result = new QueryResult<(BaseItem, ItemCounts)>();
using (new QueryTimeLogger(Logger, commandText))
- using (var connection = GetConnection())
- using (var transaction = connection.BeginTransaction(deferred: true))
+ using (var connection = GetConnection(true))
+ using (var transaction = connection.BeginTransaction())
{
if (!isReturningZeroItems)
{
@@ -5014,7 +5023,7 @@ AND Type = @InternalPersonType)");
foreach (var row in statement.ExecuteQuery())
{
- var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields);
+ var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false);
if (item is not null)
{
var countStartColumn = columns.Count - 1;
@@ -5137,12 +5146,12 @@ AND Type = @InternalPersonType)");
list.AddRange(inheritedTags.Select(i => (6, i)));
// Remove all invalid values.
- list.RemoveAll(i => string.IsNullOrEmpty(i.Item2));
+ list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2));
return list;
}
- private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, SqliteConnection db)
+ private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, ManagedConnection db)
{
if (itemId.IsEmpty())
{
@@ -5161,7 +5170,7 @@ AND Type = @InternalPersonType)");
InsertItemValues(itemId, values, db);
}
- private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, SqliteConnection db)
+ private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, ManagedConnection db)
{
const int Limit = 100;
var startIndex = 0;
@@ -5195,12 +5204,6 @@ AND Type = @InternalPersonType)");
var itemValue = currentValueInfo.Value;
- // Don't save if invalid
- if (string.IsNullOrWhiteSpace(itemValue))
- {
- continue;
- }
-
statement.TryBind("@Type" + index, currentValueInfo.MagicNumber);
statement.TryBind("@Value" + index, itemValue);
statement.TryBind("@CleanValue" + index, GetCleanValue(itemValue));
@@ -5221,24 +5224,25 @@ AND Type = @InternalPersonType)");
throw new ArgumentNullException(nameof(itemId));
}
- ArgumentNullException.ThrowIfNull(people);
-
CheckDisposed();
using var connection = GetConnection();
using var transaction = connection.BeginTransaction();
- // First delete chapters
+ // Delete all existing people first
using var command = connection.CreateCommand();
command.CommandText = "delete from People where ItemId=@ItemId";
command.TryBind("@ItemId", itemId);
command.ExecuteNonQuery();
- InsertPeople(itemId, people, connection);
+ if (people is not null)
+ {
+ InsertPeople(itemId, people, connection);
+ }
transaction.Commit();
}
- private void InsertPeople(Guid id, List<PersonInfo> people, SqliteConnection db)
+ private void InsertPeople(Guid id, List<PersonInfo> people, ManagedConnection db)
{
const int Limit = 100;
var startIndex = 0;
@@ -5334,7 +5338,7 @@ AND Type = @InternalPersonType)");
cmdText += " order by StreamIndex ASC";
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
{
var list = new List<MediaStream>();
@@ -5387,7 +5391,7 @@ AND Type = @InternalPersonType)");
transaction.Commit();
}
- private void InsertMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, SqliteConnection db)
+ private void InsertMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, ManagedConnection db)
{
const int Limit = 10;
var startIndex = 0;
@@ -5700,13 +5704,17 @@ AND Type = @InternalPersonType)");
item.Rotation = rotation;
}
- if (item.Type == MediaStreamType.Subtitle)
+ if (item.Type is MediaStreamType.Audio or MediaStreamType.Subtitle)
{
- item.LocalizedUndefined = _localization.GetLocalizedString("Undefined");
item.LocalizedDefault = _localization.GetLocalizedString("Default");
- item.LocalizedForced = _localization.GetLocalizedString("Forced");
item.LocalizedExternal = _localization.GetLocalizedString("External");
- item.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired");
+
+ if (item.Type is MediaStreamType.Subtitle)
+ {
+ item.LocalizedUndefined = _localization.GetLocalizedString("Undefined");
+ item.LocalizedForced = _localization.GetLocalizedString("Forced");
+ item.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired");
+ }
}
return item;
@@ -5728,7 +5736,7 @@ AND Type = @InternalPersonType)");
cmdText += " order by AttachmentIndex ASC";
var list = new List<MediaAttachment>();
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
using (var statement = PrepareStatement(connection, cmdText))
{
statement.TryBind("@ItemId", query.ItemId);
@@ -5778,7 +5786,7 @@ AND Type = @InternalPersonType)");
private void InsertMediaAttachments(
Guid id,
IReadOnlyList<MediaAttachment> attachments,
- SqliteConnection db,
+ ManagedConnection db,
CancellationToken cancellationToken)
{
const int InsertAtOnce = 10;
diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
index a5edcc58c..634eaf85e 100644
--- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
+++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs
@@ -58,7 +58,8 @@ namespace Emby.Server.Implementations.Data
"create unique index if not exists UserDatasIndex1 on UserDatas (key, userId)",
"create index if not exists UserDatasIndex2 on UserDatas (key, userId, played)",
"create index if not exists UserDatasIndex3 on UserDatas (key, userId, playbackPositionTicks)",
- "create index if not exists UserDatasIndex4 on UserDatas (key, userId, isFavorite)"));
+ "create index if not exists UserDatasIndex4 on UserDatas (key, userId, isFavorite)",
+ "create index if not exists UserDatasIndex5 on UserDatas (key, userId, lastPlayedDate)"));
if (!userDataTableExists)
{
@@ -85,7 +86,7 @@ namespace Emby.Server.Implementations.Data
}
}
- private void ImportUserIds(SqliteConnection db, IEnumerable<User> users)
+ private void ImportUserIds(ManagedConnection db, IEnumerable<User> users)
{
var userIdsWithUserData = GetAllUserIdsWithUserData(db);
@@ -106,7 +107,7 @@ namespace Emby.Server.Implementations.Data
}
}
- private List<Guid> GetAllUserIdsWithUserData(SqliteConnection db)
+ private List<Guid> GetAllUserIdsWithUserData(ManagedConnection db)
{
var list = new List<Guid>();
@@ -175,7 +176,7 @@ namespace Emby.Server.Implementations.Data
}
}
- private static void SaveUserData(SqliteConnection db, long internalUserId, string key, UserItemData userData)
+ private static void SaveUserData(ManagedConnection db, long internalUserId, string key, UserItemData userData)
{
using (var statement = db.PrepareStatement("replace into UserDatas (key, userId, rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex) values (@key, @userId, @rating,@played,@playCount,@isFavorite,@playbackPositionTicks,@lastPlayedDate,@AudioStreamIndex,@SubtitleStreamIndex)"))
{
@@ -266,7 +267,7 @@ namespace Emby.Server.Implementations.Data
ArgumentException.ThrowIfNullOrEmpty(key);
- using (var connection = GetConnection())
+ using (var connection = GetConnection(true))
{
using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId"))
{
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 7812687ea..19902b26a 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -668,12 +668,13 @@ namespace Emby.Server.Implementations.Dto
{
dto.ImageBlurHashes ??= new Dictionary<ImageType, Dictionary<string, string>>();
- if (!dto.ImageBlurHashes.ContainsKey(image.Type))
+ if (!dto.ImageBlurHashes.TryGetValue(image.Type, out var value))
{
- dto.ImageBlurHashes[image.Type] = new Dictionary<string, string>();
+ value = new Dictionary<string, string>();
+ dto.ImageBlurHashes[image.Type] = value;
}
- dto.ImageBlurHashes[image.Type][tag] = image.BlurHash;
+ value[tag] = image.BlurHash;
}
return tag;
@@ -897,16 +898,21 @@ namespace Emby.Server.Implementations.Dto
dto.IsPlaceHolder = supportsPlaceHolders.IsPlaceHolder;
}
- dto.LUFS = item.LUFS;
+ if (item.LUFS.HasValue)
+ {
+ // -18 LUFS reference, same as ReplayGain 2.0, compatible with ReplayGain 1.0
+ dto.NormalizationGain = -18f - item.LUFS;
+ }
+ else if (item.NormalizationGain.HasValue)
+ {
+ dto.NormalizationGain = item.NormalizationGain;
+ }
// Add audio info
if (item is Audio audio)
{
dto.Album = audio.Album;
- if (audio.ExtraType.HasValue)
- {
- dto.ExtraType = audio.ExtraType.Value.ToString();
- }
+ dto.ExtraType = audio.ExtraType;
var albumParent = audio.AlbumEntity;
@@ -1058,10 +1064,7 @@ namespace Emby.Server.Implementations.Dto
dto.Trickplay = _trickplayManager.GetTrickplayManifest(item).GetAwaiter().GetResult();
}
- if (video.ExtraType.HasValue)
- {
- dto.ExtraType = video.ExtraType.Value.ToString();
- }
+ dto.ExtraType = video.ExtraType;
}
if (options.ContainsField(ItemFields.MediaStreams))
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index f83da566b..cb6f7e1d3 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
@@ -101,14 +101,14 @@ namespace Emby.Server.Implementations.HttpServer
var pipe = new Pipe();
var writer = pipe.Writer;
- ValueWebSocketReceiveResult receiveresult;
+ ValueWebSocketReceiveResult receiveResult;
do
{
// Allocate at least 512 bytes from the PipeWriter
Memory<byte> memory = writer.GetMemory(512);
try
{
- receiveresult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
+ receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
}
catch (WebSocketException ex)
{
@@ -116,7 +116,7 @@ namespace Emby.Server.Implementations.HttpServer
break;
}
- int bytesRead = receiveresult.Count;
+ int bytesRead = receiveResult.Count;
if (bytesRead == 0)
{
break;
@@ -135,13 +135,13 @@ namespace Emby.Server.Implementations.HttpServer
LastActivityDate = DateTime.UtcNow;
- if (receiveresult.EndOfMessage)
+ if (receiveResult.EndOfMessage)
{
await ProcessInternal(pipe.Reader).ConfigureAwait(false);
}
}
while ((_socket.State == WebSocketState.Open || _socket.State == WebSocketState.Connecting)
- && receiveresult.MessageType != WebSocketMessageType.Close);
+ && receiveResult.MessageType != WebSocketMessageType.Close);
Closed?.Invoke(this, EventArgs.Empty);
@@ -199,13 +199,20 @@ namespace Emby.Server.Implementations.HttpServer
}
else
{
- await OnReceive(
- new WebSocketMessageInfo
- {
- MessageType = stub.MessageType,
- Data = stub.Data?.ToString(), // Data can be null
- Connection = this
- }).ConfigureAwait(false);
+ try
+ {
+ await OnReceive(
+ new WebSocketMessageInfo
+ {
+ MessageType = stub.MessageType,
+ Data = stub.Data?.ToString(), // Data can be null
+ Connection = this
+ }).ConfigureAwait(false);
+ }
+ catch (Exception exception)
+ {
+ _logger.LogWarning(exception, "Failed to process WebSocket message");
+ }
}
}
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
index 52f14b0b1..774d3563c 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
@@ -48,7 +48,7 @@ namespace Emby.Server.Implementations.HttpServer
WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
- using var connection = new WebSocketConnection(
+ var connection = new WebSocketConnection(
_loggerFactory.CreateLogger<WebSocketConnection>(),
webSocket,
authorizationInfo,
@@ -56,17 +56,19 @@ namespace Emby.Server.Implementations.HttpServer
{
OnReceive = ProcessWebSocketMessageReceived
};
-
- var tasks = new Task[_webSocketListeners.Length];
- for (var i = 0; i < _webSocketListeners.Length; ++i)
+ await using (connection.ConfigureAwait(false))
{
- tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection, context);
- }
+ var tasks = new Task[_webSocketListeners.Length];
+ for (var i = 0; i < _webSocketListeners.Length; ++i)
+ {
+ tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection, context);
+ }
- await Task.WhenAll(tasks).ConfigureAwait(false);
+ await Task.WhenAll(tasks).ConfigureAwait(false);
- await connection.ReceiveAsync().ConfigureAwait(false);
- _logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
+ await connection.ReceiveAsync().ConfigureAwait(false);
+ _logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
+ }
}
catch (Exception ex) // Otherwise ASP.Net will ignore the exception
{
diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
index 67854a2a7..28bb29df8 100644
--- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs
+++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
@@ -80,12 +80,14 @@ namespace Emby.Server.Implementations.IO
public virtual string MakeAbsolutePath(string folderPath, string filePath)
{
// path is actually a stream
- if (string.IsNullOrWhiteSpace(filePath) || filePath.Contains("://", StringComparison.Ordinal))
+ if (string.IsNullOrWhiteSpace(filePath))
{
return filePath;
}
- if (filePath.Length > 3 && filePath[1] == ':' && filePath[2] == '/')
+ var isAbsolutePath = Path.IsPathRooted(filePath) && (!OperatingSystem.IsWindows() || filePath[0] != '\\');
+
+ if (isAbsolutePath)
{
// absolute local path
return filePath;
@@ -97,17 +99,10 @@ namespace Emby.Server.Implementations.IO
return filePath;
}
- var firstChar = filePath[0];
- if (firstChar == '/')
- {
- // for this we don't really know
- return filePath;
- }
-
var filePathSpan = filePath.AsSpan();
- // relative path
- if (firstChar == '\\')
+ // relative path on windows
+ if (filePath[0] == '\\')
{
filePathSpan = filePathSpan.Slice(1);
}
@@ -394,7 +389,7 @@ namespace Emby.Server.Implementations.IO
var info = new FileInfo(path);
if (info.Exists &&
- ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) != isHidden)
+ (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden != isHidden)
{
if (isHidden)
{
@@ -422,8 +417,8 @@ namespace Emby.Server.Implementations.IO
return;
}
- if (((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) == readOnly
- && ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) == isHidden)
+ if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly == readOnly
+ && (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden == isHidden)
{
return;
}
@@ -471,7 +466,7 @@ namespace Emby.Server.Implementations.IO
File.Copy(file1, temp1, true);
File.Copy(file2, file1, true);
- File.Copy(temp1, file2, true);
+ File.Move(temp1, file2, true);
}
/// <inheritdoc />
diff --git a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs
index 0a3d740cc..82db7c46b 100644
--- a/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs
+++ b/Emby.Server.Implementations/Images/BaseDynamicImageProvider.cs
@@ -122,6 +122,7 @@ namespace Emby.Server.Implementations.Images
}
await ProviderManager.SaveImage(item, outputPath, mimeType, imageType, null, false, cancellationToken).ConfigureAwait(false);
+ File.Delete(outputPath);
return ItemUpdateType.ImageUpdate;
}
diff --git a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs
index 04d90af3c..f9c10ba09 100644
--- a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs
@@ -11,7 +11,6 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Images
{
@@ -33,12 +32,12 @@ namespace Emby.Server.Implementations.Images
Parent = item,
Recursive = true,
DtoOptions = new DtoOptions(true),
- ImageTypes = new ImageType[] { ImageType.Primary },
- OrderBy = new (ItemSortBy, SortOrder)[]
- {
+ ImageTypes = [ImageType.Primary],
+ OrderBy =
+ [
(ItemSortBy.IsFolder, SortOrder.Ascending),
(ItemSortBy.SortName, SortOrder.Ascending)
- },
+ ],
Limit = 1
});
}
diff --git a/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs
index ce8367363..98e26a322 100644
--- a/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs
+++ b/Emby.Server.Implementations/Images/MusicAlbumImageProvider.cs
@@ -1,7 +1,10 @@
#pragma warning disable CS1591
+using System.Collections.Generic;
+using System.Linq;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
@@ -15,5 +18,13 @@ namespace Emby.Server.Implementations.Images
: base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager)
{
}
+
+ protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item)
+ {
+ var items = base.GetItemsWithImages(item);
+
+ // Ignore any folders because they can have generated collages
+ return items.Where(i => i is not Folder).ToList();
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
index 665d70a41..b01fd93a7 100644
--- a/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
+++ b/Emby.Server.Implementations/Library/CoreResolutionIgnoreRule.cs
@@ -29,7 +29,7 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc />
- public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent)
+ public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent)
{
// Don't ignore application folders
if (fileInfo.FullName.Contains(_serverApplicationPaths.RootFolderPath, StringComparison.InvariantCulture))
diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs
index cf6fc1845..a2301c8ae 100644
--- a/Emby.Server.Implementations/Library/IgnorePatterns.cs
+++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs
@@ -103,7 +103,7 @@ namespace Emby.Server.Implementations.Library
}
};
- private static readonly Glob[] _globs = _patterns.Select(p => Glob.Parse(p, _globOptions)).ToArray();
+ private static readonly Glob[] _globs = Array.ConvertAll(_patterns, p => Glob.Parse(p, _globOptions));
/// <summary>
/// Returns true if the supplied path should be ignored.
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index a2abafd2a..cbded1ec6 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1,6 +1,5 @@
-#nullable disable
-
#pragma warning disable CS1591
+#pragma warning disable CA5394
using System;
using System.Collections.Concurrent;
@@ -18,6 +17,7 @@ using Emby.Server.Implementations.Library.Resolvers;
using Emby.Server.Implementations.Library.Validators;
using Emby.Server.Implementations.Playlists;
using Emby.Server.Implementations.ScheduledTasks.Tasks;
+using Emby.Server.Implementations.Sorting;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
@@ -89,8 +89,8 @@ namespace Emby.Server.Implementations.Library
/// <summary>
/// The _root folder.
/// </summary>
- private volatile AggregateFolder _rootFolder;
- private volatile UserRootFolder _userRootFolder;
+ private volatile AggregateFolder? _rootFolder;
+ private volatile UserRootFolder? _userRootFolder;
private bool _wizardCompleted;
@@ -155,17 +155,17 @@ namespace Emby.Server.Implementations.Library
/// <summary>
/// Occurs when [item added].
/// </summary>
- public event EventHandler<ItemChangeEventArgs> ItemAdded;
+ public event EventHandler<ItemChangeEventArgs>? ItemAdded;
/// <summary>
/// Occurs when [item updated].
/// </summary>
- public event EventHandler<ItemChangeEventArgs> ItemUpdated;
+ public event EventHandler<ItemChangeEventArgs>? ItemUpdated;
/// <summary>
/// Occurs when [item removed].
/// </summary>
- public event EventHandler<ItemChangeEventArgs> ItemRemoved;
+ public event EventHandler<ItemChangeEventArgs>? ItemRemoved;
/// <summary>
/// Gets the root folder.
@@ -264,7 +264,7 @@ namespace Emby.Server.Implementations.Library
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
- private void ConfigurationUpdated(object sender, EventArgs e)
+ private void ConfigurationUpdated(object? sender, EventArgs e)
{
var config = _configurationManager.Configuration;
@@ -338,7 +338,7 @@ namespace Emby.Server.Implementations.Library
if (item is LiveTvProgram)
{
_logger.LogDebug(
- "Removing item, Type: {0}, Name: {1}, Path: {2}, Id: {3}",
+ "Removing item, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
item.GetType().Name,
item.Name ?? "Unknown name",
item.Path ?? string.Empty,
@@ -347,7 +347,7 @@ namespace Emby.Server.Implementations.Library
else
{
_logger.LogInformation(
- "Removing item, Type: {0}, Name: {1}, Path: {2}, Id: {3}",
+ "Removing item, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
item.GetType().Name,
item.Name ?? "Unknown name",
item.Path ?? string.Empty,
@@ -366,7 +366,7 @@ namespace Emby.Server.Implementations.Library
}
_logger.LogDebug(
- "Deleting metadata path, Type: {0}, Name: {1}, Path: {2}, Id: {3}",
+ "Deleting metadata path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
item.GetType().Name,
item.Name ?? "Unknown name",
metadataPath,
@@ -395,7 +395,7 @@ namespace Emby.Server.Implementations.Library
try
{
_logger.LogInformation(
- "Deleting item path, Type: {0}, Name: {1}, Path: {2}, Id: {3}",
+ "Deleting item path, Type: {Type}, Name: {Name}, Path: {Path}, Id: {Id}",
item.GetType().Name,
item.Name ?? "Unknown name",
fileSystemInfo.FullName,
@@ -410,6 +410,24 @@ namespace Emby.Server.Implementations.Library
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)
@@ -443,7 +461,7 @@ namespace Emby.Server.Implementations.Library
ReportItemRemoved(item, parent);
}
- private static IEnumerable<string> GetMetadataPaths(BaseItem item, IEnumerable<BaseItem> children)
+ private static List<string> GetMetadataPaths(BaseItem item, IEnumerable<BaseItem> children)
{
var list = new List<string>
{
@@ -461,7 +479,7 @@ namespace Emby.Server.Implementations.Library
/// <param name="args">The args.</param>
/// <param name="resolvers">The resolvers.</param>
/// <returns>BaseItem.</returns>
- private BaseItem ResolveItem(ItemResolveArgs args, IItemResolver[] resolvers)
+ private BaseItem? ResolveItem(ItemResolveArgs args, IItemResolver[]? resolvers)
{
var item = (resolvers ?? EntityResolvers).Select(r => Resolve(args, r))
.FirstOrDefault(i => i is not null);
@@ -474,7 +492,7 @@ namespace Emby.Server.Implementations.Library
return item;
}
- private BaseItem Resolve(ItemResolveArgs args, IItemResolver resolver)
+ private BaseItem? Resolve(ItemResolveArgs args, IItemResolver resolver)
{
try
{
@@ -516,16 +534,16 @@ namespace Emby.Server.Implementations.Library
return key.GetMD5();
}
- public BaseItem ResolvePath(FileSystemMetadata fileInfo, Folder parent = null, IDirectoryService directoryService = null)
+ public BaseItem? ResolvePath(FileSystemMetadata fileInfo, Folder? parent = null, IDirectoryService? directoryService = null)
=> ResolvePath(fileInfo, directoryService ?? new DirectoryService(_fileSystem), null, parent);
- private BaseItem ResolvePath(
+ private BaseItem? ResolvePath(
FileSystemMetadata fileInfo,
IDirectoryService directoryService,
- IItemResolver[] resolvers,
- Folder parent = null,
+ IItemResolver[]? resolvers,
+ Folder? parent = null,
CollectionType? collectionType = null,
- LibraryOptions libraryOptions = null)
+ LibraryOptions? libraryOptions = null)
{
ArgumentNullException.ThrowIfNull(fileInfo);
@@ -598,7 +616,7 @@ namespace Emby.Server.Implementations.Library
return ResolveItem(args, resolvers);
}
- public bool IgnoreFile(FileSystemMetadata file, BaseItem parent)
+ public bool IgnoreFile(FileSystemMetadata file, BaseItem? parent)
=> EntityResolutionIgnoreRules.Any(r => r.ShouldIgnore(file, parent));
public List<FileSystemMetadata> NormalizeRootPathList(IEnumerable<FileSystemMetadata> paths)
@@ -673,16 +691,16 @@ namespace Emby.Server.Implementations.Library
private IEnumerable<BaseItem> ResolveFileList(
IReadOnlyList<FileSystemMetadata> fileList,
IDirectoryService directoryService,
- Folder parent,
+ Folder? parent,
CollectionType? collectionType,
- IItemResolver[] resolvers,
+ IItemResolver[]? resolvers,
LibraryOptions libraryOptions)
{
// Given that fileList is a list we can save enumerator allocations by indexing
for (var i = 0; i < fileList.Count; i++)
{
var file = fileList[i];
- BaseItem result = null;
+ BaseItem? result = null;
try
{
result = ResolvePath(file, directoryService, resolvers, parent, collectionType, libraryOptions);
@@ -711,7 +729,7 @@ namespace Emby.Server.Implementations.Library
Directory.CreateDirectory(rootFolderPath);
var rootFolder = GetItemById(GetNewItemId(rootFolderPath, typeof(AggregateFolder))) as AggregateFolder ??
- ((Folder)ResolvePath(_fileSystem.GetDirectoryInfo(rootFolderPath)))
+ (ResolvePath(_fileSystem.GetDirectoryInfo(rootFolderPath)) as Folder ?? throw new InvalidOperationException("Something went very wong"))
.DeepCopy<Folder, AggregateFolder>();
// In case program data folder was moved
@@ -777,7 +795,7 @@ namespace Emby.Server.Implementations.Library
Directory.CreateDirectory(userRootPath);
var newItemId = GetNewItemId(userRootPath, typeof(UserRootFolder));
- UserRootFolder tmpItem = null;
+ UserRootFolder? tmpItem = null;
try
{
tmpItem = GetItemById(newItemId) as UserRootFolder;
@@ -790,7 +808,8 @@ namespace Emby.Server.Implementations.Library
if (tmpItem is null)
{
_logger.LogDebug("Creating new userRootFolder with DeepCopy");
- tmpItem = ((Folder)ResolvePath(_fileSystem.GetDirectoryInfo(userRootPath))).DeepCopy<Folder, UserRootFolder>();
+ tmpItem = (ResolvePath(_fileSystem.GetDirectoryInfo(userRootPath)) as Folder ?? throw new InvalidOperationException("Failed to get user root path"))
+ .DeepCopy<Folder, UserRootFolder>();
}
// In case program data folder was moved
@@ -809,7 +828,8 @@ namespace Emby.Server.Implementations.Library
return _userRootFolder;
}
- public BaseItem FindByPath(string path, bool? isFolder)
+ /// <inheritdoc />
+ public BaseItem? FindByPath(string path, bool? isFolder)
{
// If this returns multiple items it could be tricky figuring out which one is correct.
// In most cases, the newest one will be and the others obsolete but not yet cleaned up
@@ -828,12 +848,8 @@ namespace Emby.Server.Implementations.Library
.FirstOrDefault();
}
- /// <summary>
- /// Gets the person.
- /// </summary>
- /// <param name="name">The name.</param>
- /// <returns>Task{Person}.</returns>
- public Person GetPerson(string name)
+ /// <inheritdoc />
+ public Person? GetPerson(string name)
{
var path = Person.GetPath(name);
var id = GetItemByNameId<Person>(path);
@@ -1015,7 +1031,7 @@ namespace Emby.Server.Implementations.Library
}
}
- private async Task ValidateTopLibraryFolders(CancellationToken cancellationToken)
+ public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
{
await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
@@ -1024,7 +1040,8 @@ namespace Emby.Server.Implementations.Library
new Progress<double>(),
new MetadataRefreshOptions(new DirectoryService(_fileSystem)),
recursive: false,
- cancellationToken).ConfigureAwait(false);
+ allowRemoveRoot: removeRoot,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
await GetUserRootFolder().RefreshMetadata(cancellationToken).ConfigureAwait(false);
@@ -1032,7 +1049,8 @@ namespace Emby.Server.Implementations.Library
new Progress<double>(),
new MetadataRefreshOptions(new DirectoryService(_fileSystem)),
recursive: false,
- cancellationToken).ConfigureAwait(false);
+ allowRemoveRoot: removeRoot,
+ cancellationToken: cancellationToken).ConfigureAwait(false);
// Quickly scan CollectionFolders for changes
foreach (var folder in GetUserRootFolder().Children.OfType<Folder>())
@@ -1050,7 +1068,7 @@ namespace Emby.Server.Implementations.Library
var innerProgress = new Progress<double>(pct => progress.Report(pct * 0.96));
// Validate the entire media library
- await RootFolder.ValidateChildren(innerProgress, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true, cancellationToken).ConfigureAwait(false);
+ await RootFolder.ValidateChildren(innerProgress, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), recursive: true, cancellationToken: cancellationToken).ConfigureAwait(false);
progress.Report(96);
@@ -1140,7 +1158,7 @@ namespace Emby.Server.Implementations.Library
.ToList();
}
- private VirtualFolderInfo GetVirtualFolderInfo(string dir, List<BaseItem> allCollectionFolders, HashSet<Guid> refreshQueue)
+ private VirtualFolderInfo GetVirtualFolderInfo(string dir, List<BaseItem> allCollectionFolders, HashSet<Guid>? refreshQueue)
{
var info = new VirtualFolderInfo
{
@@ -1204,20 +1222,15 @@ namespace Emby.Server.Implementations.Library
return null;
}
- /// <summary>
- /// Gets the item by id.
- /// </summary>
- /// <param name="id">The id.</param>
- /// <returns>BaseItem.</returns>
- /// <exception cref="ArgumentNullException"><paramref name="id"/> is <c>null</c>.</exception>
- public BaseItem GetItemById(Guid id)
+ /// <inheritdoc />
+ public BaseItem? GetItemById(Guid id)
{
if (id.IsEmpty())
{
throw new ArgumentException("Guid can't be empty", nameof(id));
}
- if (_cache.TryGetValue(id, out BaseItem item))
+ if (_cache.TryGetValue(id, out BaseItem? item))
{
return item;
}
@@ -1233,7 +1246,7 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc />
- public T GetItemById<T>(Guid id)
+ public T? GetItemById<T>(Guid id)
where T : BaseItem
{
var item = GetItemById(id);
@@ -1245,6 +1258,22 @@ namespace Emby.Server.Implementations.Library
return null;
}
+ /// <inheritdoc />
+ public T? GetItemById<T>(Guid id, Guid userId)
+ where T : BaseItem
+ {
+ var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId);
+ return GetItemById<T>(id, user);
+ }
+
+ /// <inheritdoc />
+ public T? GetItemById<T>(Guid id, User? user)
+ where T : BaseItem
+ {
+ var item = GetItemById<T>(id);
+ return ItemIsVisible(item, user) ? item : null;
+ }
+
public List<BaseItem> GetItemList(InternalItemsQuery query, bool allowExternalContent)
{
if (query.Recursive && !query.ParentId.IsEmpty())
@@ -1405,7 +1434,7 @@ namespace Emby.Server.Implementations.Library
var parents = new BaseItem[len];
for (int i = 0; i < len; i++)
{
- parents[i] = GetItemById(ancestorIds[i]);
+ parents[i] = GetItemById(ancestorIds[i]) ?? throw new ArgumentException($"Failed to find parent with id: {ancestorIds[i]}");
if (parents[i] is not (ICollectionFolder or UserView))
{
return;
@@ -1419,7 +1448,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()];
}
}
@@ -1516,7 +1545,7 @@ namespace Emby.Server.Implementations.Library
}
}
- private IEnumerable<Guid> GetTopParentIdsForQuery(BaseItem item, User user)
+ private IEnumerable<Guid> GetTopParentIdsForQuery(BaseItem item, User? user)
{
if (item is UserView view)
{
@@ -1585,16 +1614,20 @@ namespace Emby.Server.Implementations.Library
/// <returns>IEnumerable{System.String}.</returns>
public async Task<IEnumerable<Video>> GetIntros(BaseItem item, User user)
{
+ if (IntroProviders.Length == 0)
+ {
+ return [];
+ }
+
var tasks = IntroProviders
- .Take(1)
.Select(i => GetIntros(i, item, user));
var items = await Task.WhenAll(tasks).ConfigureAwait(false);
return items
- .SelectMany(i => i.ToArray())
+ .SelectMany(i => i)
.Select(ResolveIntro)
- .Where(i => i is not null);
+ .Where(i => i is not null)!; // null values got filtered out
}
/// <summary>
@@ -1623,9 +1656,9 @@ namespace Emby.Server.Implementations.Library
/// </summary>
/// <param name="info">The info.</param>
/// <returns>Video.</returns>
- private Video ResolveIntro(IntroInfo info)
+ private Video? ResolveIntro(IntroInfo info)
{
- Video video = null;
+ Video? video = null;
if (info.ItemId.HasValue)
{
@@ -1676,42 +1709,42 @@ namespace Emby.Server.Implementations.Library
return video;
}
- /// <summary>
- /// Sorts the specified sort by.
- /// </summary>
- /// <param name="items">The items.</param>
- /// <param name="user">The user.</param>
- /// <param name="sortBy">The sort by.</param>
- /// <param name="sortOrder">The sort order.</param>
- /// <returns>IEnumerable{BaseItem}.</returns>
- public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder)
+ /// <inheritdoc />
+ public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder)
{
- var isFirst = true;
-
- IOrderedEnumerable<BaseItem> orderedItems = null;
+ IOrderedEnumerable<BaseItem>? orderedItems = null;
foreach (var orderBy in sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null))
{
- if (isFirst)
+ if (orderBy is RandomComparer)
{
- orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, orderBy) : items.OrderBy(i => i, orderBy);
+ var randomItems = items.ToArray();
+ Random.Shared.Shuffle(randomItems);
+ items = randomItems;
+ // Items are no longer ordered at this point, so set orderedItems back to null
+ orderedItems = null;
+ }
+ else if (orderedItems is null)
+ {
+ orderedItems = sortOrder == SortOrder.Descending
+ ? items.OrderByDescending(i => i, orderBy)
+ : items.OrderBy(i => i, orderBy);
}
else
{
- orderedItems = sortOrder == SortOrder.Descending ? orderedItems.ThenByDescending(i => i, orderBy) : orderedItems.ThenBy(i => i, orderBy);
+ orderedItems = sortOrder == SortOrder.Descending
+ ? orderedItems!.ThenByDescending(i => i, orderBy)
+ : orderedItems!.ThenBy(i => i, orderBy); // orderedItems is set during the first iteration
}
-
- isFirst = false;
}
return orderedItems ?? items;
}
- public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy)
+ /// <inheritdoc />
+ public IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy)
{
- var isFirst = true;
-
- IOrderedEnumerable<BaseItem> orderedItems = null;
+ IOrderedEnumerable<BaseItem>? orderedItems = null;
foreach (var (name, sortOrder) in orderBy)
{
@@ -1721,16 +1754,26 @@ namespace Emby.Server.Implementations.Library
continue;
}
- if (isFirst)
+ if (comparer is RandomComparer)
{
- orderedItems = sortOrder == SortOrder.Descending ? items.OrderByDescending(i => i, comparer) : items.OrderBy(i => i, comparer);
+ var randomItems = items.ToArray();
+ Random.Shared.Shuffle(randomItems);
+ items = randomItems;
+ // Items are no longer ordered at this point, so set orderedItems back to null
+ orderedItems = null;
+ }
+ else if (orderedItems is null)
+ {
+ orderedItems = sortOrder == SortOrder.Descending
+ ? items.OrderByDescending(i => i, comparer)
+ : items.OrderBy(i => i, comparer);
}
else
{
- orderedItems = sortOrder == SortOrder.Descending ? orderedItems.ThenByDescending(i => i, comparer) : orderedItems.ThenBy(i => i, comparer);
+ orderedItems = sortOrder == SortOrder.Descending
+ ? orderedItems!.ThenByDescending(i => i, comparer)
+ : orderedItems!.ThenBy(i => i, comparer); // orderedItems is set during the first iteration
}
-
- isFirst = false;
}
return orderedItems ?? items;
@@ -1742,14 +1785,14 @@ namespace Emby.Server.Implementations.Library
/// <param name="name">The name.</param>
/// <param name="user">The user.</param>
/// <returns>IBaseItemComparer.</returns>
- private IBaseItemComparer GetComparer(ItemSortBy name, User user)
+ private IBaseItemComparer? GetComparer(ItemSortBy name, User? user)
{
var comparer = Comparers.FirstOrDefault(c => name == c.Type);
// If it requires a user, create a new one, and assign the user
if (comparer is IUserBaseItemComparer)
{
- var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType());
+ var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances
userComparer.User = user;
userComparer.UserManager = _userManager;
@@ -1761,23 +1804,14 @@ namespace Emby.Server.Implementations.Library
return comparer;
}
- /// <summary>
- /// Creates the item.
- /// </summary>
- /// <param name="item">The item.</param>
- /// <param name="parent">The parent item.</param>
- public void CreateItem(BaseItem item, BaseItem parent)
+ /// <inheritdoc />
+ public void CreateItem(BaseItem item, BaseItem? parent)
{
CreateItems(new[] { item }, parent, CancellationToken.None);
}
- /// <summary>
- /// Creates the items.
- /// </summary>
- /// <param name="items">The items.</param>
- /// <param name="parent">The parent item.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem parent, CancellationToken cancellationToken)
+ /// <inheritdoc />
+ public void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken)
{
_itemRepository.SaveItems(items, cancellationToken);
@@ -1860,7 +1894,7 @@ namespace Emby.Server.Implementations.Library
try
{
var index = item.GetImageIndex(img);
- image = await ConvertImageToLocal(item, img, index, removeOnFailure: true).ConfigureAwait(false);
+ image = await ConvertImageToLocal(item, img, index, true).ConfigureAwait(false);
}
catch (ArgumentException)
{
@@ -2059,16 +2093,16 @@ namespace Emby.Server.Implementations.Library
public LibraryOptions GetLibraryOptions(BaseItem item)
{
- if (item is not CollectionFolder collectionFolder)
+ if (item is CollectionFolder collectionFolder)
{
- // List.Find is more performant than FirstOrDefault due to enumerator allocation
- collectionFolder = GetCollectionFolders(item)
- .Find(folder => folder is CollectionFolder) as CollectionFolder;
+ return collectionFolder.GetLibraryOptions();
}
- return collectionFolder is null
- ? new LibraryOptions()
- : collectionFolder.GetLibraryOptions();
+ // List.Find is more performant than FirstOrDefault due to enumerator allocation
+ return GetCollectionFolders(item)
+ .Find(folder => folder is CollectionFolder) is CollectionFolder collectionFolder2
+ ? collectionFolder2.GetLibraryOptions()
+ : new LibraryOptions();
}
public CollectionType? GetContentType(BaseItem item)
@@ -2422,7 +2456,7 @@ namespace Emby.Server.Implementations.Library
{
if (parentId.HasValue)
{
- return GetItemById(parentId.Value);
+ return GetItemById(parentId.Value) ?? throw new ArgumentException($"Invalid parent id: {parentId.Value}");
}
if (!userId.IsNullOrEmpty())
@@ -2459,7 +2493,7 @@ 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;
+ EpisodeInfo? episodeInfo = null;
if (episode.IsFileProtocol)
{
episodeInfo = resolver.Resolve(episode.Path, isFolder, null, null, isAbsoluteNaming);
@@ -2662,7 +2696,7 @@ namespace Emby.Server.Implementations.Library
}
}
- BaseItem GetExtra(FileSystemMetadata file, ExtraType extraType)
+ BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType)
{
var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType));
if (extra is not Video && extra is not Audio)
@@ -2677,16 +2711,21 @@ namespace Emby.Server.Implementations.Library
extra = itemById;
}
- extra.ExtraType = extraType;
+ // Only update extra type if it is more specific then the currently known extra type
+ if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown)
+ {
+ extra.ExtraType = extraType;
+ }
+
extra.ParentId = Guid.Empty;
extra.OwnerId = owner.Id;
return extra;
}
}
- public string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem)
+ public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem)
{
- string newPath;
+ string? newPath;
if (ownerItem is not null)
{
var libraryOptions = GetLibraryOptions(ownerItem);
@@ -2760,8 +2799,8 @@ namespace Emby.Server.Implementations.Library
}
})
.Where(i => i is not null)
- .Where(i => query.User is null || i.IsVisible(query.User))
- .ToList();
+ .Where(i => query.User is null || i!.IsVisible(query.User))
+ .ToList()!; // null values are filtered out
}
public List<string> GetPeopleNames(InternalPeopleQuery query)
@@ -2783,8 +2822,10 @@ namespace Emby.Server.Implementations.Library
}
_itemRepository.UpdatePeople(item.Id, people);
-
- await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false);
+ if (people is not null)
+ {
+ await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false);
+ }
}
public async Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex, bool removeOnFailure)
@@ -2863,7 +2904,7 @@ namespace Emby.Server.Implementations.Library
if (collectionType is not null)
{
- var path = Path.Combine(virtualFolderPath, collectionType.ToString().ToLowerInvariant() + ".collection");
+ var path = Path.Combine(virtualFolderPath, collectionType.ToString()!.ToLowerInvariant() + ".collection"); // Can't be null with legal values?
await File.WriteAllBytesAsync(path, Array.Empty<byte>()).ConfigureAwait(false);
}
@@ -2897,7 +2938,7 @@ namespace Emby.Server.Implementations.Library
private async Task SavePeopleMetadataAsync(IEnumerable<PersonInfo> people, CancellationToken cancellationToken)
{
- List<BaseItem> personsToSave = null;
+ List<BaseItem>? personsToSave = null;
foreach (var person in people)
{
@@ -3010,9 +3051,7 @@ namespace Emby.Server.Implementations.Library
{
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
- var list = libraryOptions.PathInfos.ToList();
- list.Add(pathInfo);
- libraryOptions.PathInfos = list.ToArray();
+ libraryOptions.PathInfos = [..libraryOptions.PathInfos, pathInfo];
SyncLibraryOptionsToLocations(virtualFolderPath, libraryOptions);
@@ -3031,8 +3070,7 @@ namespace Emby.Server.Implementations.Library
SyncLibraryOptionsToLocations(virtualFolderPath, libraryOptions);
- var list = libraryOptions.PathInfos.ToList();
- foreach (var originalPathInfo in list)
+ foreach (var originalPathInfo in libraryOptions.PathInfos)
{
if (string.Equals(mediaPath.Path, originalPathInfo.Path, StringComparison.Ordinal))
{
@@ -3041,8 +3079,6 @@ namespace Emby.Server.Implementations.Library
}
}
- libraryOptions.PathInfos = list.ToArray();
-
CollectionFolder.SaveLibraryOptions(virtualFolderPath, libraryOptions);
}
@@ -3095,7 +3131,7 @@ namespace Emby.Server.Implementations.Library
if (refreshLibrary)
{
- await ValidateTopLibraryFolders(CancellationToken.None).ConfigureAwait(false);
+ await ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
StartScanInBackground();
}
@@ -3115,7 +3151,7 @@ namespace Emby.Server.Implementations.Library
throw new ArgumentNullException(nameof(path));
}
- List<NameValuePair> removeList = null;
+ List<NameValuePair>? removeList = null;
foreach (var contentType in _configurationManager.Configuration.ContentTypes)
{
@@ -3168,5 +3204,20 @@ namespace Emby.Server.Implementations.Library
CollectionFolder.SaveLibraryOptions(virtualFolderPath, libraryOptions);
}
+
+ private static bool ItemIsVisible(BaseItem? item, User? user)
+ {
+ if (item is null)
+ {
+ return false;
+ }
+
+ if (user is null)
+ {
+ return true;
+ }
+
+ return item is UserRootFolder || item.IsVisibleStandalone(user);
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 18ada6aeb..bb22ca82f 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -113,6 +113,11 @@ namespace Emby.Server.Implementations.Library
return true;
}
+ if (stream.IsPgsSubtitleStream)
+ {
+ return true;
+ }
+
return false;
}
@@ -191,7 +196,7 @@ namespace Emby.Server.Implementations.Library
if (user is not null)
{
- SetDefaultAudioAndSubtitleStreamIndexes(item, source, user);
+ SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
if (item.MediaType == MediaType.Audio)
{
@@ -274,7 +279,7 @@ namespace Emby.Server.Implementations.Library
var tasks = _providers.Select(i => GetDynamicMediaSources(item, i, cancellationToken));
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
- return results.SelectMany(i => i.ToList());
+ return results.SelectMany(i => i);
}
private async Task<IEnumerable<MediaSourceInfo>> GetDynamicMediaSources(BaseItem item, IMediaSourceProvider provider, CancellationToken cancellationToken)
@@ -296,7 +301,7 @@ namespace Emby.Server.Implementations.Library
catch (Exception ex)
{
_logger.LogError(ex, "Error getting media sources");
- return Enumerable.Empty<MediaSourceInfo>();
+ return [];
}
}
@@ -339,7 +344,7 @@ namespace Emby.Server.Implementations.Library
{
foreach (var source in sources)
{
- SetDefaultAudioAndSubtitleStreamIndexes(item, source, user);
+ SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
if (item.MediaType == MediaType.Audio)
{
@@ -360,7 +365,7 @@ namespace Emby.Server.Implementations.Library
{
if (string.IsNullOrEmpty(language))
{
- return Array.Empty<string>();
+ return [];
}
var culture = _localizationManager.FindLanguageInfo(language);
@@ -369,14 +374,15 @@ namespace Emby.Server.Implementations.Library
return culture.ThreeLetterISOLanguageNames;
}
- return new string[] { language };
+ return [language];
}
private void SetDefaultSubtitleStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
{
if (userData.SubtitleStreamIndex.HasValue
&& user.RememberSubtitleSelections
- && user.SubtitleMode != SubtitlePlaybackMode.None && allowRememberingSelection)
+ && user.SubtitleMode != SubtitlePlaybackMode.None
+ && allowRememberingSelection)
{
var index = userData.SubtitleStreamIndex.Value;
// Make sure the saved index is still valid
@@ -390,7 +396,7 @@ namespace Emby.Server.Implementations.Library
var preferredSubs = NormalizeLanguage(user.SubtitleLanguagePreference);
var defaultAudioIndex = source.DefaultAudioStreamIndex;
- var audioLangage = defaultAudioIndex is null
+ var audioLanguage = defaultAudioIndex is null
? null
: source.MediaStreams.Where(i => i.Type == MediaStreamType.Audio && i.Index == defaultAudioIndex).Select(i => i.Language).FirstOrDefault();
@@ -398,9 +404,9 @@ namespace Emby.Server.Implementations.Library
source.MediaStreams,
preferredSubs,
user.SubtitleMode,
- audioLangage);
+ audioLanguage);
- MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLangage);
+ MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage);
}
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
@@ -421,7 +427,7 @@ namespace Emby.Server.Implementations.Library
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack);
}
- public void SetDefaultAudioAndSubtitleStreamIndexes(BaseItem item, MediaSourceInfo source, User user)
+ public void SetDefaultAudioAndSubtitleStreamIndices(BaseItem item, MediaSourceInfo source, User user)
{
// Item would only be null if the app didn't supply ItemId as part of the live stream open request
var mediaType = item?.MediaType ?? MediaType.Video;
@@ -526,7 +532,7 @@ namespace Emby.Server.Implementations.Library
var item = request.ItemId.IsEmpty()
? null
: _libraryManager.GetItemById(request.ItemId);
- SetDefaultAudioAndSubtitleStreamIndexes(item, clone, user);
+ SetDefaultAudioAndSubtitleStreamIndices(item, clone, user);
}
return new Tuple<LiveStreamResponse, IDirectStreamProvider>(new LiveStreamResponse(clone), liveStream as IDirectStreamProvider);
diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
index 6aef87c52..ea223e3ec 100644
--- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs
+++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
@@ -124,16 +124,16 @@ namespace Emby.Server.Implementations.Library
}
else if (mode == SubtitlePlaybackMode.Always)
{
- // always load the most suitable full subtitles
+ // Always load the most suitable full subtitles
filteredStreams = sortedStreams.Where(s => !s.IsForced).ToList();
}
else if (mode == SubtitlePlaybackMode.OnlyForced)
{
- // always load the most suitable full subtitles
+ // Always load the most suitable full subtitles
filteredStreams = sortedStreams.Where(s => s.IsForced).ToList();
}
- // load forced subs if we have found no suitable full subtitles
+ // Load forced subs if we have found no suitable full subtitles
var iterStreams = filteredStreams is null || filteredStreams.Count == 0
? sortedStreams.Where(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
: filteredStreams;
diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs
index c4b6b3756..21e7079d8 100644
--- a/Emby.Server.Implementations/Library/PathExtensions.cs
+++ b/Emby.Server.Implementations/Library/PathExtensions.cs
@@ -31,8 +31,9 @@ namespace Emby.Server.Implementations.Library
var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
- // Must be at least 3 characters after the attribute =, ], any character.
- var maxIndex = str.Length - attribute.Length - 3;
+ // Must be at least 3 characters after the attribute =, ], any character,
+ // then we offset it by 1, because we want the index and not length.
+ var maxIndex = str.Length - attribute.Length - 2;
while (attributeIndex > -1 && attributeIndex < maxIndex)
{
var attributeEnd = attributeIndex + attribute.Length;
diff --git a/Emby.Server.Implementations/Library/ResolverHelper.cs b/Emby.Server.Implementations/Library/ResolverHelper.cs
index 7a61e2607..c9e3a4daf 100644
--- a/Emby.Server.Implementations/Library/ResolverHelper.cs
+++ b/Emby.Server.Implementations/Library/ResolverHelper.cs
@@ -35,11 +35,11 @@ namespace Emby.Server.Implementations.Library
item.Id = libraryManager.GetNewItemId(item.Path, item.GetType());
- item.IsLocked = item.Path.IndexOf("[dontfetchmeta]", StringComparison.OrdinalIgnoreCase) != -1 ||
+ item.IsLocked = item.Path.Contains("[dontfetchmeta]", StringComparison.OrdinalIgnoreCase) ||
item.GetParents().Any(i => i.IsLocked);
// Make sure DateCreated and DateModified have values
- var fileInfo = directoryService.GetFile(item.Path);
+ var fileInfo = directoryService.GetFileSystemEntry(item.Path);
if (fileInfo is null)
{
return false;
diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs
index 0bfb7fbe6..9405f2102 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicAlbumResolver.cs
@@ -13,7 +13,6 @@ using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Resolvers;
-using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs
index 1bdae7f62..f7270bec1 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Audio/MusicArtistResolver.cs
@@ -3,6 +3,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
+using Emby.Naming.Audio;
using Emby.Naming.Common;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.Audio;
@@ -85,6 +86,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
}
var albumResolver = new MusicAlbumResolver(_logger, _namingOptions, _directoryService);
+ var albumParser = new AlbumParser(_namingOptions);
var directories = args.FileSystemChildren.Where(i => i.IsDirectory);
@@ -100,6 +102,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
}
}
+ // If the folder is a multi-disc folder, then it is not an artist folder
+ if (albumParser.IsMultiPart(fileSystemInfo.FullName))
+ {
+ return;
+ }
+
// If we contain a music album assume we are an artist folder
if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, _directoryService))
{
diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs
index 6cc04ea81..955055313 100644
--- a/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/Movies/BoxSetResolver.cs
@@ -33,7 +33,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
return null;
}
- if (filename.IndexOf("[boxset]", StringComparison.OrdinalIgnoreCase) != -1 || args.ContainsFileSystemEntryByName("collection.xml"))
+ if (filename.Contains("[boxset]", StringComparison.OrdinalIgnoreCase) || args.ContainsFileSystemEntryByName("collection.xml"))
{
return new BoxSet
{
diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
index a50435ae6..a03c1214d 100644
--- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
@@ -1,7 +1,5 @@
#nullable disable
-#pragma warning disable CS1591
-
using System;
using System.IO;
using System.Linq;
@@ -11,7 +9,6 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.LocalMetadata.Savers;
-using MediaBrowser.Model.Entities;
namespace Emby.Server.Implementations.Library.Resolvers
{
@@ -20,11 +17,11 @@ namespace Emby.Server.Implementations.Library.Resolvers
/// </summary>
public class PlaylistResolver : GenericFolderResolver<Playlist>
{
- private CollectionType?[] _musicPlaylistCollectionTypes =
- {
+ private readonly CollectionType?[] _musicPlaylistCollectionTypes =
+ [
null,
CollectionType.music
- };
+ ];
/// <inheritdoc/>
protected override Playlist Resolve(ItemResolveArgs args)
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
index 858c5b281..abf2d0115 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
@@ -54,7 +54,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
{
IndexNumber = seasonParserResult.SeasonNumber,
SeriesId = series.Id,
- SeriesName = series.Name
+ SeriesName = series.Name,
+ Path = seasonParserResult.IsSeasonFolder ? path : null
};
if (!season.IndexNumber.HasValue || !seasonParserResult.IsSeasonFolder)
@@ -78,27 +79,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
}
}
- if (season.IndexNumber.HasValue)
+ if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name))
{
var seasonNumber = season.IndexNumber.Value;
- if (string.IsNullOrEmpty(season.Name))
- {
- var seasonNames = series.SeasonNames;
- if (seasonNames.TryGetValue(seasonNumber, out var seasonName))
- {
- season.Name = seasonName;
- }
- else
- {
- season.Name = seasonNumber == 0 ?
- args.LibraryOptions.SeasonZeroDisplayName :
- string.Format(
- CultureInfo.InvariantCulture,
- _localization.GetLocalizedString("NameSeasonNumber"),
- seasonNumber,
- args.LibraryOptions.PreferredMetadataLanguage);
- }
- }
+ season.Name = seasonNumber == 0 ?
+ args.LibraryOptions.SeasonZeroDisplayName :
+ string.Format(
+ CultureInfo.InvariantCulture,
+ _localization.GetLocalizedString("NameSeasonNumber"),
+ seasonNumber,
+ args.LibraryOptions.PreferredMetadataLanguage);
}
return season;
diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs
index 83a66c8e4..d9a559014 100644
--- a/Emby.Server.Implementations/Library/UserViewManager.cs
+++ b/Emby.Server.Implementations/Library/UserViewManager.cs
@@ -303,8 +303,8 @@ namespace Emby.Server.Implementations.Library
{
// Handle situations with the grouping setting, e.g. movies showing up in tv, etc.
// Thanks to mixed content libraries included in the UserView
- var hasCollectionType = parents.OfType<UserView>().ToArray();
- if (hasCollectionType.Length > 0)
+ var hasCollectionType = parents.OfType<UserView>().ToList();
+ if (hasCollectionType.Count > 0)
{
if (hasCollectionType.All(i => i.CollectionType == CollectionType.movies))
{
diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
index 601aab5b9..725b8f76c 100644
--- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
+++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs
@@ -64,6 +64,11 @@ namespace Emby.Server.Implementations.Library.Validators
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))
{
@@ -92,7 +97,7 @@ namespace Emby.Server.Implementations.Library.Validators
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
{
- IncludeItemTypes = new[] { BaseItemKind.Person },
+ IncludeItemTypes = [BaseItemKind.Person],
IsDeadPerson = true,
IsLocked = false
});
diff --git a/Emby.Server.Implementations/Localization/Core/ab.json b/Emby.Server.Implementations/Localization/Core/ab.json
index 0967ef424..bc6062f42 100644
--- a/Emby.Server.Implementations/Localization/Core/ab.json
+++ b/Emby.Server.Implementations/Localization/Core/ab.json
@@ -1 +1,3 @@
-{}
+{
+ "Albums": "аальбомқәа"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/af.json b/Emby.Server.Implementations/Localization/Core/af.json
index ecea8df6a..e89ede10b 100644
--- a/Emby.Server.Implementations/Localization/Core/af.json
+++ b/Emby.Server.Implementations/Localization/Core/af.json
@@ -5,12 +5,12 @@
"Favorites": "Gunstelinge",
"HeaderFavoriteShows": "Gunsteling Vertonings",
"ValueSpecialEpisodeName": "Spesiale - {0}",
- "HeaderAlbumArtists": "Kunstenaars se Album",
+ "HeaderAlbumArtists": "Album kunstenaars",
"Books": "Boeke",
"HeaderNextUp": "Volgende",
"Movies": "Flieks",
"Shows": "Televisie Reekse",
- "HeaderContinueWatching": "Kyk Verder",
+ "HeaderContinueWatching": "Hou aan kyk",
"HeaderFavoriteEpisodes": "Gunsteling Episodes",
"Photos": "Foto's",
"Playlists": "Snitlyste",
@@ -19,7 +19,7 @@
"Sync": "Sinkroniseer",
"HeaderFavoriteSongs": "Gunsteling Liedjies",
"Songs": "Liedjies",
- "DeviceOnlineWithName": "{0} is gekoppel",
+ "DeviceOnlineWithName": "{0} is aanlyn",
"DeviceOfflineWithName": "{0} is ontkoppel",
"Collections": "Versamelings",
"Inherit": "Ontvang",
@@ -61,7 +61,7 @@
"NotificationOptionPluginInstalled": "Inprop module geïnstalleer",
"NotificationOptionPluginError": "Inprop module het misluk",
"NotificationOptionNewLibraryContent": "Nuwe inhoud bygevoeg",
- "NotificationOptionInstallationFailed": "Installering het misluk",
+ "NotificationOptionInstallationFailed": "Installasie mislukking",
"NotificationOptionCameraImageUploaded": "Kamera foto is opgelaai",
"NotificationOptionAudioPlaybackStopped": "Oudio terugspeel het gestop",
"NotificationOptionAudioPlayback": "Oudio terugspeel het begin",
@@ -86,9 +86,9 @@
"HomeVideos": "Tuis Videos",
"HeaderRecordingGroups": "Groep Opnames",
"Genres": "Genres",
- "FailedLoginAttemptWithUserName": "Mislukte aansluiting van {0}",
+ "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}",
"ChapterNameValue": "Hoofstuk {0}",
- "CameraImageUploadedFrom": "'n Nuwe kamera photo opgelaai van {0}",
+ "CameraImageUploadedFrom": "'n Nuwe kamera foto is opgelaai vanaf {0}",
"AuthenticationSucceededWithUserName": "{0} suksesvol geverifieer",
"Albums": "Albums",
"TasksChannelsCategory": "Internet kanale",
@@ -114,8 +114,8 @@
"TaskRefreshChapterImagesDescription": "Maak kleinkiekeis (fotos) vir films wat hoofstukke het.",
"TaskRefreshChapterImages": "Verkry Hoofstuk Beelde",
"Undefined": "Ongedefineerd",
- "Forced": "Geforseer",
- "Default": "Oorspronklik",
+ "Forced": "Geforseerd",
+ "Default": "Standaard",
"TaskCleanActivityLogDescription": "Verwyder aktiwiteitsaantekeninge ouer as die opgestelde ouderdom.",
"TaskCleanActivityLog": "Maak Aktiwiteitsaantekeninge Skoon",
"TaskOptimizeDatabaseDescription": "Komprimeer databasis en verkort vrye ruimte. As hierdie taak uitgevoer word nadat die media versameling geskandeer is of ander veranderings aangebring is wat databasisaanpassings impliseer, kan dit die prestasie verbeter.",
@@ -125,5 +125,9 @@
"External": "Ekstern",
"HearingImpaired": "gehoorgestremd",
"TaskRefreshTrickplayImages": "Genereer Fopspeel Beelde",
- "TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling."
+ "TaskRefreshTrickplayImagesDescription": "Skep fopspeel voorskou vir videos in aangeskakelde media versameling.",
+ "TaskAudioNormalizationDescription": "Skandeer lêers vir oudio-normaliseringsdata.",
+ "TaskAudioNormalization": "Odio Normalisering",
+ "TaskCleanCollectionsAndPlaylists": "Maak versamelings en snitlyste skoon",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Verwyder items uit versamelings en snitlyste wat nie meer bestaan nie."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json
index 35387d032..4245656ff 100644
--- a/Emby.Server.Implementations/Localization/Core/ar.json
+++ b/Emby.Server.Implementations/Localization/Core/ar.json
@@ -11,7 +11,7 @@
"Collections": "التجميعات",
"DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
"DeviceOnlineWithName": "{0} متصل",
- "FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فشلت من {0}",
+ "FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}",
"Favorites": "المفضلة",
"Folders": "المجلدات",
"Genres": "التصنيفات",
@@ -126,5 +126,9 @@
"External": "خارجي",
"HearingImpaired": "ضعاف السمع",
"TaskRefreshTrickplayImages": "توليد صور Trickplay",
- "TaskRefreshTrickplayImagesDescription": "يُنشئ معاينات Trickplay لمقاطع الفيديو في المكتبات المُمكّنة."
+ "TaskRefreshTrickplayImagesDescription": "يُنشئ معاينات Trickplay لمقاطع الفيديو في المكتبات المُمكّنة.",
+ "TaskCleanCollectionsAndPlaylists": "حذف المجموعات وقوائم التشغيل",
+ "TaskCleanCollectionsAndPlaylistsDescription": "حذف عناصر من المجموعات وقوائم التشغيل التي لم تعد موجودة.",
+ "TaskAudioNormalization": "تطبيع الصوت",
+ "TaskAudioNormalizationDescription": "مسح الملفات لتطبيع بيانات الصوت."
}
diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json
index 05af8d8a5..9172af516 100644
--- a/Emby.Server.Implementations/Localization/Core/be.json
+++ b/Emby.Server.Implementations/Localization/Core/be.json
@@ -52,7 +52,7 @@
"UserDownloadingItemWithValues": "{0} спампоўваецца {1}",
"TaskOptimizeDatabase": "Аптымізаваць базу дадзеных",
"Artists": "Выканаўцы",
- "UserOfflineFromDevice": "{0} адключыўся ад {1}",
+ "UserOfflineFromDevice": "{0} адлучыўся ад {1}",
"UserPolicyUpdatedWithName": "Палітыка карыстальніка абноўлена для {0}",
"TaskCleanActivityLogDescription": "Выдаляе старэйшыя за зададзены ўзрост запісы ў журнале актыўнасці.",
"TaskRefreshChapterImagesDescription": "Стварае мініяцюры для відэа, якія маюць раздзелы.",
@@ -66,7 +66,7 @@
"AppDeviceValues": "Прыкладанне: {0}, Прылада: {1}",
"Books": "Кнігі",
"CameraImageUploadedFrom": "Новая выява камеры была загружана з {0}",
- "DeviceOfflineWithName": "{0} адключыўся",
+ "DeviceOfflineWithName": "{0} адлучыўся",
"DeviceOnlineWithName": "{0} падлучаны",
"Forced": "Прымусова",
"HeaderRecordingGroups": "Групы запісаў",
@@ -125,5 +125,9 @@
"TaskDownloadMissingSubtitles": "Спампаваць адсутныя субтытры",
"TaskKeyframeExtractorDescription": "Выдае ключавыя кадры з відэафайлаў для стварэння больш дакладных спісаў прайгравання HLS. Гэта задача можа працаваць у працягу доўгага часу.",
"TaskRefreshTrickplayImages": "Стварыце выявы Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Стварае прагляд відэаролікаў для Trickplay у падключаных бібліятэках."
+ "TaskRefreshTrickplayImagesDescription": "Стварае прагляд відэаролікаў для Trickplay у падключаных бібліятэках.",
+ "TaskCleanCollectionsAndPlaylists": "Ачысціце калекцыі і спісы прайгравання",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Выдаляе элементы з калекцый і спісаў прайгравання, якія больш не існуюць.",
+ "TaskAudioNormalizationDescription": "Сканіруе файлы на прадмет нармалізацыі гуку.",
+ "TaskAudioNormalization": "Нармалізацыя гуку"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json
index c4d8c6947..2998489b5 100644
--- a/Emby.Server.Implementations/Localization/Core/ca.json
+++ b/Emby.Server.Implementations/Localization/Core/ca.json
@@ -126,5 +126,9 @@
"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."
+ "TaskRefreshTrickplayImagesDescription": "Crear miniatures de línia de temps per vídeos en les biblioteques habilitades.",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Esborra elements de col·leccions i llistes de reproducció que ja no existeixen.",
+ "TaskCleanCollectionsAndPlaylists": "Neteja col·leccions i llistes de reproducció",
+ "TaskAudioNormalization": "Normalització d'Àudio",
+ "TaskAudioNormalizationDescription": "Escaneja arxius per dades de normalització d'àudio."
}
diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json
index 1c7bc75b5..14cfeb71a 100644
--- a/Emby.Server.Implementations/Localization/Core/cs.json
+++ b/Emby.Server.Implementations/Localization/Core/cs.json
@@ -22,7 +22,7 @@
"HeaderFavoriteEpisodes": "Oblíbené epizody",
"HeaderFavoriteShows": "Oblíbené seriály",
"HeaderFavoriteSongs": "Oblíbená hudba",
- "HeaderLiveTV": "Živý přenos",
+ "HeaderLiveTV": "TV vysílání",
"HeaderNextUp": "Další díly",
"HeaderRecordingGroups": "Skupiny nahrávek",
"HomeVideos": "Domácí videa",
@@ -126,5 +126,9 @@
"External": "Externí",
"HearingImpaired": "Sluchově postižení",
"TaskRefreshTrickplayImages": "Generovat obrázky pro Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Obrázky Trickplay se používají k zobrazení náhledů u videí v knihovnách, kde je to povoleno."
+ "TaskRefreshTrickplayImagesDescription": "Obrázky Trickplay se používají k zobrazení náhledů u videí v knihovnách, kde je to povoleno.",
+ "TaskCleanCollectionsAndPlaylists": "Pročistit kolekce a seznamy přehrávání",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Odstraní neexistující položky z kolekcí a seznamů přehrávání.",
+ "TaskAudioNormalization": "Normalizace zvuku",
+ "TaskAudioNormalizationDescription": "Skenovat soubory za účelem normalizace zvuku."
}
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index 092af34b6..e871a4362 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -17,7 +17,7 @@
"Genres": "Genrer",
"HeaderAlbumArtists": "Albumkunstnere",
"HeaderContinueWatching": "Fortsæt afspilning",
- "HeaderFavoriteAlbums": "Favoritalbummer",
+ "HeaderFavoriteAlbums": "Favoritalbum",
"HeaderFavoriteArtists": "Favoritkunstnere",
"HeaderFavoriteEpisodes": "Yndlingsafsnit",
"HeaderFavoriteShows": "Yndlingsserier",
@@ -87,21 +87,21 @@
"UserOnlineFromDevice": "{0} er online fra {1}",
"UserPasswordChangedWithName": "Adgangskode er ændret for brugeren {0}",
"UserPolicyUpdatedWithName": "Brugerpolitikken er blevet opdateret for {0}",
- "UserStartedPlayingItemWithValues": "{0} har påbegyndt afspilning af {1}",
+ "UserStartedPlayingItemWithValues": "{0} afspiller {1} på {2}",
"UserStoppedPlayingItemWithValues": "{0} har afsluttet afspilning af {1} på {2}",
"ValueHasBeenAddedToLibrary": "{0} er blevet tilføjet til dit mediebibliotek",
"ValueSpecialEpisodeName": "Special - {0}",
"VersionNumber": "Version {0}",
"TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.",
"TaskDownloadMissingSubtitles": "Hent manglende undertekster",
- "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er indstillet til at blive opdateret automatisk.",
+ "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.",
"TaskUpdatePlugins": "Opdater Plugins",
"TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.",
"TaskCleanLogs": "Ryd Log-mappe",
"TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.",
"TaskRefreshLibrary": "Scan Mediebibliotek",
"TaskCleanCacheDescription": "Sletter cache-filer som systemet ikke længere bruger.",
- "TaskCleanCache": "Ryd Cache-mappe",
+ "TaskCleanCache": "Ryd cache-mappe",
"TasksChannelsCategory": "Internetkanaler",
"TasksApplicationCategory": "Applikation",
"TasksLibraryCategory": "Bibliotek",
@@ -126,5 +126,9 @@
"External": "Ekstern",
"HearingImpaired": "Hørehæmmet",
"TaskRefreshTrickplayImages": "Generér Trickplay Billeder",
- "TaskRefreshTrickplayImagesDescription": "Laver trickplay forhåndsvisninger for videoer i aktiverede biblioteker."
+ "TaskRefreshTrickplayImagesDescription": "Laver trickplay forhåndsvisninger for videoer i aktiverede biblioteker.",
+ "TaskCleanCollectionsAndPlaylists": "Ryd op i samlinger og afspilningslister",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra samlinger og afspilningslister der ikke eksisterer længere.",
+ "TaskAudioNormalizationDescription": "Skanner filer for data vedrørende audio-normalisering.",
+ "TaskAudioNormalization": "Audio-normalisering"
}
diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json
index 7a4c2067b..ce98979e6 100644
--- a/Emby.Server.Implementations/Localization/Core/de.json
+++ b/Emby.Server.Implementations/Localization/Core/de.json
@@ -126,5 +126,9 @@
"External": "Extern",
"HearingImpaired": "Hörgeschädigt",
"TaskRefreshTrickplayImages": "Trickplay-Bilder generieren",
- "TaskRefreshTrickplayImagesDescription": "Erstellt eine Trickplay-Vorschau für Videos in aktivierten Bibliotheken."
+ "TaskRefreshTrickplayImagesDescription": "Erstellt ein Trickplay-Vorschauen für Videos in aktivierten Bibliotheken.",
+ "TaskCleanCollectionsAndPlaylists": "Sammlungen und Playlisten aufräumen",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Lösche nicht mehr vorhandene Einträge aus den Sammlungen und Playlisten.",
+ "TaskAudioNormalization": "Audio Normalisierung",
+ "TaskAudioNormalizationDescription": "Durchsucht Dateien nach Audionormalisierungsdaten."
}
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index 5ea6a2252..056a2e475 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -126,5 +126,9 @@
"External": "Εξωτερικό",
"HearingImpaired": "Με προβλήματα ακοής",
"TaskRefreshTrickplayImages": "Δημιουργήστε εικόνες Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Δημιουργεί προεπισκοπήσεις trickplay για βίντεο σε ενεργοποιημένες βιβλιοθήκες."
+ "TaskRefreshTrickplayImagesDescription": "Δημιουργεί προεπισκοπήσεις trickplay για βίντεο σε ενεργοποιημένες βιβλιοθήκες.",
+ "TaskAudioNormalization": "Ομοιομορφία ήχου",
+ "TaskAudioNormalizationDescription": "Ανίχνευση αρχείων για δεδομένα ομοιομορφίας ήχου.",
+ "TaskCleanCollectionsAndPlaylists": "Καθαρισμός συλλογών και λιστών αναπαραγωγής",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Αφαιρούνται στοιχεία από τις συλλογές και τις λίστες αναπαραγωγής που δεν υπάρχουν πλέον."
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json
index 32bf89310..75285fe8e 100644
--- a/Emby.Server.Implementations/Localization/Core/en-GB.json
+++ b/Emby.Server.Implementations/Localization/Core/en-GB.json
@@ -126,5 +126,9 @@
"External": "External",
"HearingImpaired": "Hearing Impaired",
"TaskRefreshTrickplayImages": "Generate Trickplay Images",
- "TaskRefreshTrickplayImagesDescription": "Creates trickplay previews for videos in enabled libraries."
+ "TaskRefreshTrickplayImagesDescription": "Creates trickplay previews for videos in enabled libraries.",
+ "TaskCleanCollectionsAndPlaylists": "Clean up collections and playlists",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Removes items from collections and playlists that no longer exist.",
+ "TaskAudioNormalization": "Audio Normalisation",
+ "TaskAudioNormalizationDescription": "Scans files for audio normalisation data."
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json
index 4ba31bee0..1a69627fa 100644
--- a/Emby.Server.Implementations/Localization/Core/en-US.json
+++ b/Emby.Server.Implementations/Localization/Core/en-US.json
@@ -13,7 +13,7 @@
"DeviceOfflineWithName": "{0} has disconnected",
"DeviceOnlineWithName": "{0} is connected",
"External": "External",
- "FailedLoginAttemptWithUserName": "Failed login try from {0}",
+ "FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
"Favorites": "Favorites",
"Folders": "Folders",
"Forced": "Forced",
@@ -106,6 +106,8 @@
"TaskCleanCacheDescription": "Deletes cache files no longer needed by the system.",
"TaskRefreshChapterImages": "Extract Chapter Images",
"TaskRefreshChapterImagesDescription": "Creates thumbnails for videos that have chapters.",
+ "TaskAudioNormalization": "Audio Normalization",
+ "TaskAudioNormalizationDescription": "Scans files for audio normalization data.",
"TaskRefreshLibrary": "Scan Media Library",
"TaskRefreshLibraryDescription": "Scans your media library for new files and refreshes metadata.",
"TaskCleanLogs": "Clean Log Directory",
diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json
index d677cc46c..e9ace71a5 100644
--- a/Emby.Server.Implementations/Localization/Core/es-MX.json
+++ b/Emby.Server.Implementations/Localization/Core/es-MX.json
@@ -11,7 +11,7 @@
"Collections": "Colecciones",
"DeviceOfflineWithName": "{0} se ha desconectado",
"DeviceOnlineWithName": "{0} está conectado",
- "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión desde {0}",
+ "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}",
"Favorites": "Favoritos",
"Folders": "Carpetas",
"Genres": "Géneros",
@@ -124,5 +124,11 @@
"TaskKeyframeExtractorDescription": "Extrae los cuadros clave de los archivos de vídeo para crear listas HLS más precisas. Esta tarea puede tardar un buen rato.",
"TaskKeyframeExtractor": "Extractor de Cuadros Clave",
"External": "Externo",
- "HearingImpaired": "Discapacidad Auditiva"
+ "HearingImpaired": "Discapacidad Auditiva",
+ "TaskRefreshTrickplayImagesDescription": "Crea previsualizaciones para la barra de reproducción en las bibliotecas habilitadas.",
+ "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción",
+ "TaskAudioNormalization": "Normalización de audio",
+ "TaskAudioNormalizationDescription": "Analiza los archivos para normalizar el audio.",
+ "TaskCleanCollectionsAndPlaylists": "Limpieza de colecciones y listas de reproducción",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Quita elementos que ya no existen de colecciones y listas de reproducción."
}
diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json
index fe10be308..13e007b4c 100644
--- a/Emby.Server.Implementations/Localization/Core/es.json
+++ b/Emby.Server.Implementations/Localization/Core/es.json
@@ -11,7 +11,7 @@
"Collections": "Colecciones",
"DeviceOfflineWithName": "{0} se ha desconectado",
"DeviceOnlineWithName": "{0} está conectado",
- "FailedLoginAttemptWithUserName": "Error al intentar iniciar sesión desde {0}",
+ "FailedLoginAttemptWithUserName": "Intento fallido de inicio de sesión de {0}",
"Favorites": "Favoritos",
"Folders": "Carpetas",
"Genres": "Géneros",
@@ -30,7 +30,7 @@
"ItemAddedWithName": "{0} se ha añadido a la biblioteca",
"ItemRemovedWithName": "{0} ha sido eliminado de la biblioteca",
"LabelIpAddressValue": "Dirección IP: {0}",
- "LabelRunningTimeValue": "Tiempo de funcionamiento: {0}",
+ "LabelRunningTimeValue": "Duración: {0}",
"Latest": "Últimas",
"MessageApplicationUpdated": "Se ha actualizado el servidor Jellyfin",
"MessageApplicationUpdatedTo": "Se ha actualizado el servidor Jellyfin a la versión {0}",
@@ -126,5 +126,9 @@
"External": "Externo",
"HearingImpaired": "Discapacidad Auditiva",
"TaskRefreshTrickplayImages": "Generar miniaturas de línea de tiempo",
- "TaskRefreshTrickplayImagesDescription": "Crear miniaturas de tiempo para videos en las librerías habilitadas."
+ "TaskRefreshTrickplayImagesDescription": "Crear miniaturas de tiempo para videos en las librerías habilitadas.",
+ "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Elimina elementos de colecciones y listas de reproducción que ya no existen.",
+ "TaskAudioNormalization": "Normalización de audio",
+ "TaskAudioNormalizationDescription": "Escanear archivos para obtener datos de normalización."
}
diff --git a/Emby.Server.Implementations/Localization/Core/es_419.json b/Emby.Server.Implementations/Localization/Core/es_419.json
index c6863ff36..e7deefbb0 100644
--- a/Emby.Server.Implementations/Localization/Core/es_419.json
+++ b/Emby.Server.Implementations/Localization/Core/es_419.json
@@ -112,7 +112,7 @@
"CameraImageUploadedFrom": "Una nueva imagen de cámara ha sido subida desde {0}",
"AuthenticationSucceededWithUserName": "{0} autenticado con éxito",
"Application": "Aplicación",
- "AppDeviceValues": "App: {0}, Dispositivo: {1}",
+ "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}",
"TaskCleanActivityLogDescription": "Elimina las entradas del registro de actividad anteriores al periodo configurado.",
"TaskCleanActivityLog": "Limpiar registro de actividades",
"Undefined": "Sin definir",
@@ -125,5 +125,9 @@
"TaskKeyframeExtractor": "Extractor de Fotogramas Clave",
"HearingImpaired": "Discapacidad auditiva",
"TaskRefreshTrickplayImagesDescription": "Crea previsualizaciones para la barra de reproducción en las bibliotecas habilitadas.",
- "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción"
+ "TaskRefreshTrickplayImages": "Generar imágenes de la barra de reproducción",
+ "TaskAudioNormalization": "Normalización de audio",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Quita elementos que ya no existen de colecciones y listas de reproducción.",
+ "TaskAudioNormalizationDescription": "Analiza los archivos para normalizar el audio.",
+ "TaskCleanCollectionsAndPlaylists": "Limpieza de colecciones y listas de reproducción"
}
diff --git a/Emby.Server.Implementations/Localization/Core/es_DO.json b/Emby.Server.Implementations/Localization/Core/es_DO.json
index 0f4c7438f..8cdd06b7c 100644
--- a/Emby.Server.Implementations/Localization/Core/es_DO.json
+++ b/Emby.Server.Implementations/Localization/Core/es_DO.json
@@ -12,14 +12,118 @@
"Application": "Aplicación",
"AppDeviceValues": "App: {0}, Dispositivo: {1}",
"HeaderContinueWatching": "Continuar Viendo",
- "HeaderAlbumArtists": "Artistas del Álbum",
+ "HeaderAlbumArtists": "Artistas del álbum",
"Genres": "Géneros",
"Folders": "Carpetas",
"Favorites": "Favoritos",
- "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido de {0}",
+ "FailedLoginAttemptWithUserName": "Intento de inicio de sesión fallido desde {0}",
"HeaderFavoriteSongs": "Canciones Favoritas",
"HeaderFavoriteEpisodes": "Episodios Favoritos",
"HeaderFavoriteArtists": "Artistas Favoritos",
"External": "Externo",
- "Default": "Predeterminado"
+ "Default": "Predeterminado",
+ "Movies": "Películas",
+ "MessageNamedServerConfigurationUpdatedWithValue": "La sección {0} de la configuración ha sido actualizada",
+ "MixedContent": "Contenido mixto",
+ "Music": "Música",
+ "NotificationOptionCameraImageUploaded": "Imagen de la cámara subida",
+ "NotificationOptionServerRestartRequired": "Se necesita reiniciar el servidor",
+ "NotificationOptionVideoPlayback": "Reproducción de video iniciada",
+ "Sync": "Sincronizar",
+ "Shows": "Series",
+ "UserDownloadingItemWithValues": "{0} está descargando {1}",
+ "UserOfflineFromDevice": "{0} se ha desconectado desde {1}",
+ "UserOnlineFromDevice": "{0} está en línea desde {1}",
+ "TasksChannelsCategory": "Canales de Internet",
+ "TaskRefreshChannelsDescription": "Actualiza la información de canales de Internet.",
+ "TaskDownloadMissingSubtitles": "Descargar subtítulos faltantes",
+ "TaskOptimizeDatabaseDescription": "Compacta la base de datos y libera espacio. Ejecutar esta tarea después de escanear la biblioteca o hacer otros cambios que impliquen modificaciones en la base de datos puede mejorar el rendimiento.",
+ "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reproducción HLS más precisas. Esta tarea puede durar mucho tiempo.",
+ "TaskAudioNormalization": "Normalización de audio",
+ "TaskAudioNormalizationDescription": "Escanear archivos para la normalización de data.",
+ "TaskCleanCollectionsAndPlaylists": "Limpiar colecciones y listas de reproducción",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Remover elementos de colecciones y listas de reproducción que no existen.",
+ "TvShows": "Series de TV",
+ "UserStartedPlayingItemWithValues": "{0} está reproduciendo {1} en {2}",
+ "TaskRefreshChannels": "Actualizar canales",
+ "Photos": "Fotos",
+ "HeaderFavoriteShows": "Programas favoritos",
+ "TaskCleanActivityLog": "Limpiar registro de actividades",
+ "UserPasswordChangedWithName": "Se ha cambiado la contraseña para el usuario {0}",
+ "System": "Sistema",
+ "User": "Usuario",
+ "Forced": "Forzado",
+ "PluginInstalledWithName": "{0} ha sido instalado",
+ "HeaderFavoriteAlbums": "Álbumes favoritos",
+ "TaskUpdatePlugins": "Actualizar Plugins",
+ "Latest": "Recientes",
+ "UserStoppedPlayingItemWithValues": "{0} ha terminado de reproducir {1} en {2}",
+ "Songs": "Canciones",
+ "NotificationOptionPluginError": "Falla de plugin",
+ "ScheduledTaskStartedWithName": "{0} iniciado",
+ "TasksApplicationCategory": "Aplicación",
+ "UserDeletedWithName": "El usuario {0} ha sido eliminado",
+ "TaskRefreshChapterImages": "Extraer imágenes de los capítulos",
+ "TaskUpdatePluginsDescription": "Descarga e instala actualizaciones para plugins que están configurados para actualizarse automáticamente.",
+ "TaskRefreshPeopleDescription": "Actualiza metadatos de actores y directores en tu biblioteca de medios.",
+ "NotificationOptionUserLockedOut": "Usuario bloqueado",
+ "TaskCleanTranscodeDescription": "Elimina archivos transcodificados que tengan más de un día.",
+ "TaskCleanTranscode": "Limpiar el directorio de transcodificaciones",
+ "NotificationOptionPluginUpdateInstalled": "Actualización de plugin instalada",
+ "NotificationOptionAudioPlaybackStopped": "Reproducción de audio detenida",
+ "TasksLibraryCategory": "Biblioteca",
+ "NotificationOptionPluginInstalled": "Plugin instalado",
+ "UserPolicyUpdatedWithName": "La política de usuario ha sido actualizada para {0}",
+ "VersionNumber": "Versión {0}",
+ "HeaderNextUp": "A continuación",
+ "ValueHasBeenAddedToLibrary": "{0} se ha añadido a tu biblioteca",
+ "LabelIpAddressValue": "Dirección IP: {0}",
+ "NameSeasonNumber": "Temporada {0}",
+ "NotificationOptionNewLibraryContent": "Nuevo contenido agregado",
+ "Plugin": "Plugin",
+ "NotificationOptionAudioPlayback": "Reproducción de audio iniciada",
+ "NotificationOptionTaskFailed": "Falló la tarea programada",
+ "LabelRunningTimeValue": "Tiempo en ejecución: {0}",
+ "SubtitleDownloadFailureFromForItem": "Falló la descarga de subtítulos desde {0} para {1}",
+ "TaskRefreshLibrary": "Escanear biblioteca de medios",
+ "ServerNameNeedsToBeRestarted": "{0} debe ser reiniciado",
+ "TasksMaintenanceCategory": "Mantenimiento",
+ "ProviderValue": "Proveedor: {0}",
+ "UserCreatedWithName": "El usuario {0} ha sido creado",
+ "PluginUninstalledWithName": "{0} ha sido desinstalado",
+ "ValueSpecialEpisodeName": "Especial - {0}",
+ "ScheduledTaskFailedWithName": "{0} falló",
+ "TaskCleanLogs": "Limpiar directorio de registros",
+ "NameInstallFailed": "Falló la instalación de {0}",
+ "UserLockedOutWithName": "El usuario {0} ha sido bloqueado",
+ "TaskRefreshLibraryDescription": "Escanea tu biblioteca de medios para encontrar archivos nuevos y actualizar los metadatos.",
+ "StartupEmbyServerIsLoading": "El servidor Jellyfin está cargando. Por favor, intente de nuevo en un momento.",
+ "Playlists": "Listas de reproducción",
+ "TaskDownloadMissingSubtitlesDescription": "Busca subtítulos faltantes en Internet basándose en la configuración de metadatos.",
+ "MessageServerConfigurationUpdated": "Se ha actualizado la configuración del servidor",
+ "TaskRefreshPeople": "Actualizar personas",
+ "NotificationOptionVideoPlaybackStopped": "Reproducción de video detenida",
+ "HeaderLiveTV": "TV en vivo",
+ "NameSeasonUnknown": "Temporada desconocida",
+ "NotificationOptionInstallationFailed": "Fallo de instalación",
+ "NotificationOptionPluginUninstalled": "Plugin desinstalado",
+ "TaskCleanCache": "Limpiar directorio caché",
+ "TaskRefreshChapterImagesDescription": "Crea miniaturas para videos que tienen capítulos.",
+ "Inherit": "Heredar",
+ "HeaderRecordingGroups": "Grupos de grabación",
+ "ItemAddedWithName": "{0} fue agregado a la biblioteca",
+ "TaskOptimizeDatabase": "Optimizar base de datos",
+ "TaskKeyframeExtractor": "Extractor de Fotogramas Clave",
+ "HearingImpaired": "Discapacidad auditiva",
+ "HomeVideos": "Videos caseros",
+ "ItemRemovedWithName": "{0} fue removido de la biblioteca",
+ "MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado",
+ "MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}",
+ "MusicVideos": "Videos musicales",
+ "NewVersionIsAvailable": "Una nueva versión de Jellyfin está disponible para descargar.",
+ "PluginUpdatedWithName": "{0} ha sido actualizado",
+ "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."
}
diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json
index 977307b06..075bcc9a4 100644
--- a/Emby.Server.Implementations/Localization/Core/et.json
+++ b/Emby.Server.Implementations/Localization/Core/et.json
@@ -125,5 +125,9 @@
"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."
+ "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.",
+ "TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json
index 8364ce236..ce5177d1f 100644
--- a/Emby.Server.Implementations/Localization/Core/fa.json
+++ b/Emby.Server.Implementations/Localization/Core/fa.json
@@ -126,5 +126,7 @@
"External": "خارجی",
"HearingImpaired": "مشکل شنوایی",
"TaskRefreshTrickplayImages": "تولید تصاویر Trickplay",
- "TaskRefreshTrickplayImagesDescription": "تولید پیش‌نمایش های trickplay برای ویدیو های فعال شده در کتابخانه."
+ "TaskRefreshTrickplayImagesDescription": "تولید پیش‌نمایش های trickplay برای ویدیو های فعال شده در کتابخانه.",
+ "TaskCleanCollectionsAndPlaylists": "پاکسازی مجموعه ها و لیست پخش",
+ "TaskCleanCollectionsAndPlaylistsDescription": "موارد را از مجموعه ها و لیست پخش هایی که دیگر وجود ندارند حذف میکند."
}
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
index cba036ff4..dced61c5e 100644
--- a/Emby.Server.Implementations/Localization/Core/fi.json
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -125,5 +125,9 @@
"External": "Ulkoinen",
"HearingImpaired": "Kuulorajoitteinen",
"TaskRefreshTrickplayImages": "Luo Trickplay-kuvat",
- "TaskRefreshTrickplayImagesDescription": "Luo Trickplay-esikatselut käytössä olevien kirjastojen videoista."
+ "TaskRefreshTrickplayImagesDescription": "Luo Trickplay-esikatselut käytössä olevien kirjastojen videoista.",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Poistaa kohteet kokoelmista ja soittolistoista joita ei ole enää olemassa.",
+ "TaskCleanCollectionsAndPlaylists": "Puhdista kokoelmat ja soittolistat",
+ "TaskAudioNormalization": "Äänenvoimakkuuden normalisointi",
+ "TaskAudioNormalizationDescription": "Etsii tiedostoista äänenvoimakkuuden normalisointitietoja."
}
diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json
index b816738c2..42027dfb2 100644
--- a/Emby.Server.Implementations/Localization/Core/fr-CA.json
+++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json
@@ -11,7 +11,7 @@
"Collections": "Collections",
"DeviceOfflineWithName": "{0} s'est déconnecté",
"DeviceOnlineWithName": "{0} est connecté",
- "FailedLoginAttemptWithUserName": "Tentative de connexion échoué par {0}",
+ "FailedLoginAttemptWithUserName": "Tentative de connexion échouée par {0}",
"Favorites": "Favoris",
"Folders": "Dossiers",
"Genres": "Genres",
@@ -39,7 +39,7 @@
"MixedContent": "Contenu mixte",
"Movies": "Films",
"Music": "Musique",
- "MusicVideos": "Vidéos musicales",
+ "MusicVideos": "Vidéoclips",
"NameInstallFailed": "échec d'installation de {0}",
"NameSeasonNumber": "Saison {0}",
"NameSeasonUnknown": "Saison Inconnue",
@@ -126,5 +126,9 @@
"External": "Externe",
"HearingImpaired": "Malentendants",
"TaskRefreshTrickplayImages": "Générer des images Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées."
+ "TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées.",
+ "TaskCleanCollectionsAndPlaylists": "Nettoyer les collections et les listes de lecture",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Supprime les éléments des collections et des listes de lecture qui n'existent plus.",
+ "TaskAudioNormalization": "Normalisation audio",
+ "TaskAudioNormalizationDescription": "Analyse les fichiers à la recherche de données de normalisation audio."
}
diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json
index d04a79de1..a13ee48d5 100644
--- a/Emby.Server.Implementations/Localization/Core/fr.json
+++ b/Emby.Server.Implementations/Localization/Core/fr.json
@@ -126,5 +126,9 @@
"External": "Externe",
"HearingImpaired": "Malentendants",
"TaskRefreshTrickplayImages": "Générer des images Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées."
+ "TaskRefreshTrickplayImagesDescription": "Crée des aperçus Trickplay pour les vidéos dans les médiathèques activées.",
+ "TaskCleanCollectionsAndPlaylists": "Nettoyer les collections et les listes de lecture",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Supprime les éléments des collections et des listes de lecture qui n'existent plus.",
+ "TaskAudioNormalization": "Normalisation audio",
+ "TaskAudioNormalizationDescription": "Analyse les fichiers à la recherche de données de normalisation audio."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json
index 28e54bff5..b511ed6ba 100644
--- a/Emby.Server.Implementations/Localization/Core/ga.json
+++ b/Emby.Server.Implementations/Localization/Core/ga.json
@@ -1,3 +1,16 @@
{
- "Albums": "Albaim"
+ "Albums": "Albaim",
+ "Artists": "Ealaíontóir",
+ "AuthenticationSucceededWithUserName": "{0} fíordheimhnithe",
+ "Books": "leabhair",
+ "CameraImageUploadedFrom": "Tá íomhá ceamara nua uaslódáilte ó {0}",
+ "Channels": "Cainéil",
+ "ChapterNameValue": "Caibidil {0}",
+ "Collections": "Bailiúcháin",
+ "Default": "Mainneachtain",
+ "DeviceOfflineWithName": "scoireadh {0}",
+ "DeviceOnlineWithName": "{0} ceangailte",
+ "External": "Forimeallach",
+ "FailedLoginAttemptWithUserName": "Iarracht ar theip ar fhíordheimhniú ó {0}",
+ "Favorites": "Ceanáin"
}
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index 26eab392e..c8e036424 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -126,5 +126,9 @@
"External": "חיצוני",
"HearingImpaired": "לקוי שמיעה",
"TaskRefreshTrickplayImages": "יצירת תמונות המחשה",
- "TaskRefreshTrickplayImagesDescription": "יוצר תמונות המחשה לסרטונים שפעילים בספריות."
+ "TaskRefreshTrickplayImagesDescription": "יוצר תמונות המחשה לסרטונים שפעילים בספריות.",
+ "TaskAudioNormalization": "נרמול שמע",
+ "TaskCleanCollectionsAndPlaylistsDescription": "מנקה פריטים לא קיימים מאוספים ורשימות השמעה.",
+ "TaskAudioNormalizationDescription": "מחפש קבצי נורמליזציה של שמע.",
+ "TaskCleanCollectionsAndPlaylists": "מנקה אוספים ורשימות השמעה"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json
index a28352219..380c08e0d 100644
--- a/Emby.Server.Implementations/Localization/Core/hi.json
+++ b/Emby.Server.Implementations/Localization/Core/hi.json
@@ -14,7 +14,7 @@
"Forced": "बलपूर्वक",
"Folders": "फ़ोल्डर",
"Favorites": "पसंदीदा",
- "FailedLoginAttemptWithUserName": "{0} से लॉगिन असफल हुआ",
+ "FailedLoginAttemptWithUserName": "{0} से संप्रवेश असफल हुआ",
"DeviceOnlineWithName": "{0} कनेक्ट हो गया है",
"DeviceOfflineWithName": "{0} डिस्कनेक्ट हो गया है",
"Default": "प्राथमिक",
@@ -125,5 +125,7 @@
"TaskDownloadMissingSubtitlesDescription": "मेटाडेटा कॉन्फ़िगरेशन के आधार पर लापता उपशीर्षक के लिए इंटरनेट खोजता है।",
"TaskKeyframeExtractorDescription": "अधिक सटीक एचएलएस प्लेलिस्ट बनाने के लिए वीडियो फ़ाइलों से मुख्य-फ़्रेम निकालता है। यह कार्य लंबे समय तक चल सकता है।",
"TaskRefreshTrickplayImages": "ट्रिकप्लै चित्रों को सृजन करे",
- "TaskRefreshTrickplayImagesDescription": "नियत संग्रहों में चलचित्रों का ट्रीकप्लै दर्शनों को सृजन करे."
+ "TaskRefreshTrickplayImagesDescription": "नियत संग्रहों में चलचित्रों का ट्रीकप्लै दर्शनों को सृजन करे.",
+ "TaskAudioNormalization": "श्रव्य सामान्यीकरण",
+ "TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index 5bb2b7d4d..6a5b8c561 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -126,5 +126,6 @@
"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."
+ "TaskRefreshTrickplayImagesDescription": "Kreira trickplay pretpreglede za videe u omogućenim knjižnicama.",
+ "TaskAudioNormalization": "Normalizacija zvuka"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
index ba3d5872a..31d6aaedb 100644
--- a/Emby.Server.Implementations/Localization/Core/hu.json
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -1,13 +1,13 @@
{
"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",
"Books": "Könyvek",
- "CameraImageUploadedFrom": "Új kamerakép feltöltve innen: {0}",
+ "CameraImageUploadedFrom": "Új kamerakép lett feltöltve innen: {0}",
"Channels": "Csatornák",
- "ChapterNameValue": "{0}. jelenet",
+ "ChapterNameValue": "Jelenet {0}",
"Collections": "Gyűjtemények",
"DeviceOfflineWithName": "{0} kijelentkezett",
"DeviceOnlineWithName": "{0} belépett",
@@ -15,27 +15,27 @@
"Favorites": "Kedvencek",
"Folders": "Könyvtárak",
"Genres": "Műfajok",
- "HeaderAlbumArtists": "Albumelőadók",
+ "HeaderAlbumArtists": "Album előadók",
"HeaderContinueWatching": "Megtekintés folytatása",
- "HeaderFavoriteAlbums": "Kedvenc albumok",
- "HeaderFavoriteArtists": "Kedvenc előadók",
- "HeaderFavoriteEpisodes": "Kedvenc epizódok",
- "HeaderFavoriteShows": "Kedvenc sorozatok",
- "HeaderFavoriteSongs": "Kedvenc számok",
+ "HeaderFavoriteAlbums": "Kedvenc Albumok",
+ "HeaderFavoriteArtists": "Kedvenc Előadók",
+ "HeaderFavoriteEpisodes": "Kedvenc Epizódok",
+ "HeaderFavoriteShows": "Kedvenc Sorozatok",
+ "HeaderFavoriteSongs": "Kedvenc Dalok",
"HeaderLiveTV": "Élő TV",
"HeaderNextUp": "Következik",
- "HeaderRecordingGroups": "Felvételi csoportok",
- "HomeVideos": "Házi videók",
+ "HeaderRecordingGroups": "Felvevő Csoportok",
+ "HomeVideos": "Otthoni Videók",
"Inherit": "Örökölt",
"ItemAddedWithName": "{0} hozzáadva a könyvtárhoz",
"ItemRemovedWithName": "{0} eltávolítva a könyvtárból",
"LabelIpAddressValue": "IP-cím: {0}",
"LabelRunningTimeValue": "Lejátszási idő: {0}",
"Latest": "Legújabb",
- "MessageApplicationUpdated": "A Jellyfin kiszolgáló frissítve",
+ "MessageApplicationUpdated": "A Jellyfin kiszolgáló frissítve lett",
"MessageApplicationUpdatedTo": "A Jellyfin kiszolgáló frissítve lett a következőre: {0}",
- "MessageNamedServerConfigurationUpdatedWithValue": "A kiszolgálókonfigurációs rész frissítve: {0}",
- "MessageServerConfigurationUpdated": "Kiszolgálókonfiguráció frissítve",
+ "MessageNamedServerConfigurationUpdatedWithValue": "A kiszolgálókonfigurációs rész frissítve lett: {0}",
+ "MessageServerConfigurationUpdated": "Kiszolgálókonfiguráció frissítve lett",
"MixedContent": "Vegyes tartalom",
"Movies": "Filmek",
"Music": "Zenék",
@@ -46,7 +46,7 @@
"NewVersionIsAvailable": "Letölthető a Jellyfin kiszolgáló új verziója.",
"NotificationOptionApplicationUpdateAvailable": "Frissítés érhető el az alkalmazáshoz",
"NotificationOptionApplicationUpdateInstalled": "Alkalmazásfrissítés telepítve",
- "NotificationOptionAudioPlayback": "Hanglejátszás elkezdve",
+ "NotificationOptionAudioPlayback": "Hanglejátszás elkezdődött",
"NotificationOptionAudioPlaybackStopped": "Hanglejátszás leállítva",
"NotificationOptionCameraImageUploaded": "Kamerakép feltöltve",
"NotificationOptionInstallationFailed": "Telepítési hiba",
@@ -126,5 +126,9 @@
"External": "Külső",
"HearingImpaired": "Hallássérült",
"TaskRefreshTrickplayImages": "Trickplay képek generálása",
- "TaskRefreshTrickplayImagesDescription": "Trickplay előnézetet készít az engedélyezett könyvtárakban lévő videókhoz."
+ "TaskRefreshTrickplayImagesDescription": "Trickplay előnézetet készít az engedélyezett könyvtárakban lévő videókhoz.",
+ "TaskAudioNormalization": "Hangerő Normalizáció",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Nem létező elemek törlése a gyűjteményekből és lejátszási listákról.",
+ "TaskAudioNormalizationDescription": "Hangerő normalizációs adatok keresése.",
+ "TaskCleanCollectionsAndPlaylists": "Gyűjtemények és lejátszási listák optimalizálása"
}
diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json
index 78a443348..b925a482b 100644
--- a/Emby.Server.Implementations/Localization/Core/id.json
+++ b/Emby.Server.Implementations/Localization/Core/id.json
@@ -81,7 +81,7 @@
"Movies": "Film",
"MessageServerConfigurationUpdated": "Konfigurasi server telah diperbarui",
"MessageNamedServerConfigurationUpdatedWithValue": "Bagian konfigurasi server {0} telah diperbarui",
- "FailedLoginAttemptWithUserName": "Gagal melakukan login dari {0}",
+ "FailedLoginAttemptWithUserName": "Gagal upaya login dari {0}",
"CameraImageUploadedFrom": "Sebuah gambar kamera baru telah diunggah dari {0}",
"DeviceOfflineWithName": "{0} telah terputus",
"DeviceOnlineWithName": "{0} telah terhubung",
@@ -125,5 +125,9 @@
"External": "Luar",
"HearingImpaired": "Gangguan Pendengaran",
"TaskRefreshTrickplayImages": "Hasilkan Gambar Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Buat pratinjau trickplay untuk video di perpustakaan yang diaktifkan."
+ "TaskRefreshTrickplayImagesDescription": "Buat pratinjau trickplay untuk video di perpustakaan yang diaktifkan.",
+ "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."
}
diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json
index 0f1f0b3d2..6cb55760a 100644
--- a/Emby.Server.Implementations/Localization/Core/is.json
+++ b/Emby.Server.Implementations/Localization/Core/is.json
@@ -17,7 +17,7 @@
"Genres": "Stefnur",
"Folders": "Möppur",
"Favorites": "Uppáhalds",
- "FailedLoginAttemptWithUserName": "{0} reyndi að auðkenna sig",
+ "FailedLoginAttemptWithUserName": "{0} mistókst að auðkenna sig",
"DeviceOnlineWithName": "{0} hefur tengst",
"DeviceOfflineWithName": "{0} hefur aftengst",
"Collections": "Söfn",
@@ -123,5 +123,11 @@
"TaskRefreshChapterImages": "Plokka kafla-myndir",
"TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.",
"Forced": "Þvingað",
- "External": "Útvær"
+ "External": "Útvær",
+ "TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir fyrir myndbönd í virkum söfnum.",
+ "TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir",
+ "TaskAudioNormalization": "Hljóðstöðlun",
+ "TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.",
+ "TaskCleanCollectionsAndPlaylists": "Hreinsa söfn og spilunarlista",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Fjarlægir hluti úr söfnum og spilalistum sem eru ekki lengur til."
}
diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json
index a34bcc490..0e694af02 100644
--- a/Emby.Server.Implementations/Localization/Core/it.json
+++ b/Emby.Server.Implementations/Localization/Core/it.json
@@ -51,10 +51,10 @@
"NotificationOptionCameraImageUploaded": "Immagine fotocamera caricata",
"NotificationOptionInstallationFailed": "Installazione fallita",
"NotificationOptionNewLibraryContent": "Nuovo contenuto aggiunto",
- "NotificationOptionPluginError": "Errore del Plug-in",
- "NotificationOptionPluginInstalled": "Plug-in installato",
- "NotificationOptionPluginUninstalled": "Plug-in disinstallato",
- "NotificationOptionPluginUpdateInstalled": "Aggiornamento del plug-in installato",
+ "NotificationOptionPluginError": "Errore del plugin",
+ "NotificationOptionPluginInstalled": "Plugin installato",
+ "NotificationOptionPluginUninstalled": "Plugin disinstallato",
+ "NotificationOptionPluginUpdateInstalled": "Aggiornamento plugin installato",
"NotificationOptionServerRestartRequired": "Riavvio del server necessario",
"NotificationOptionTaskFailed": "Operazione pianificata fallita",
"NotificationOptionUserLockedOut": "Utente bloccato",
@@ -68,10 +68,10 @@
"PluginUpdatedWithName": "{0} è stato aggiornato",
"ProviderValue": "Provider: {0}",
"ScheduledTaskFailedWithName": "{0} fallito",
- "ScheduledTaskStartedWithName": "{0} avviati",
+ "ScheduledTaskStartedWithName": "{0} avviato",
"ServerNameNeedsToBeRestarted": "{0} deve essere riavviato",
"Shows": "Serie TV",
- "Songs": "Canzoni",
+ "Songs": "Brani",
"StartupEmbyServerIsLoading": "Jellyfin server si sta avviando. Per favore riprova più tardi.",
"SubtitleDownloadFailureForItem": "Impossibile scaricare i sottotitoli per {0}",
"SubtitleDownloadFailureFromForItem": "Impossibile scaricare i sottotitoli da {0} per {1}",
@@ -83,48 +83,52 @@
"UserDeletedWithName": "L'utente {0} è stato rimosso",
"UserDownloadingItemWithValues": "{0} sta scaricando {1}",
"UserLockedOutWithName": "L'utente {0} è stato bloccato",
- "UserOfflineFromDevice": "{0} si è disconnesso su {1}",
+ "UserOfflineFromDevice": "{0} si è disconnesso da {1}",
"UserOnlineFromDevice": "{0} è online su {1}",
"UserPasswordChangedWithName": "La password è stata cambiata per l'utente {0}",
"UserPolicyUpdatedWithName": "La policy dell'utente è stata aggiornata per {0}",
- "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di \"{1}\" su {2}",
+ "UserStartedPlayingItemWithValues": "{0} ha avviato la riproduzione di {1} su {2}",
"UserStoppedPlayingItemWithValues": "{0} ha interrotto la riproduzione di {1} su {2}",
"ValueHasBeenAddedToLibrary": "{0} è stato aggiunto alla tua libreria multimediale",
"ValueSpecialEpisodeName": "Speciale - {0}",
"VersionNumber": "Versione {0}",
- "TaskRefreshChannelsDescription": "Aggiorna le informazioni dei canali Internet.",
+ "TaskRefreshChannelsDescription": "Aggiorna le informazioni dei canali internet.",
"TaskDownloadMissingSubtitlesDescription": "Cerca su internet i sottotitoli mancanti basandosi sulle configurazioni dei metadati.",
"TaskDownloadMissingSubtitles": "Scarica i sottotitoli mancanti",
- "TaskRefreshChannels": "Aggiorna i canali",
- "TaskCleanTranscodeDescription": "Cancella i file di transcode più vecchi di un giorno.",
- "TaskCleanTranscode": "Svuota la cartella del transcoding",
- "TaskUpdatePluginsDescription": "Scarica e installa gli aggiornamenti per i plugin che sono stati configurati per essere aggiornati contemporaneamente.",
- "TaskUpdatePlugins": "Aggiorna i Plugin",
- "TaskRefreshPeopleDescription": "Aggiorna i metadati per gli attori e registi nella tua libreria multimediale.",
- "TaskRefreshPeople": "Aggiornamento Persone",
+ "TaskRefreshChannels": "Aggiorna canali",
+ "TaskCleanTranscodeDescription": "Cancella i file di transcodifica più vecchi di un giorno.",
+ "TaskCleanTranscode": "Svuota la cartella della transcodifica",
+ "TaskUpdatePluginsDescription": "Scarica e installa gli aggiornamenti per i plugin configurati per l'aggiornamento automatico.",
+ "TaskUpdatePlugins": "Aggiorna i plugin",
+ "TaskRefreshPeopleDescription": "Aggiorna i metadati degli attori e registi nella tua libreria.",
+ "TaskRefreshPeople": "Aggiorna Persone",
"TaskCleanLogsDescription": "Rimuovi i file di log più vecchi di {0} giorni.",
"TaskCleanLogs": "Pulisci la cartella dei log",
- "TaskRefreshLibraryDescription": "Analizza la tua libreria multimediale per nuovi file e rinnova i metadati.",
- "TaskRefreshLibrary": "Scan Librerie",
- "TaskRefreshChapterImagesDescription": "Crea le thumbnail per i video che hanno capitoli.",
+ "TaskRefreshLibraryDescription": "Scansiona la libreria alla ricerca di nuovi file e aggiorna i metadati.",
+ "TaskRefreshLibrary": "Scansione della libreria",
+ "TaskRefreshChapterImagesDescription": "Crea le miniature per i video che hanno capitoli.",
"TaskRefreshChapterImages": "Estrai immagini capitolo",
"TaskCleanCacheDescription": "Cancella i file di cache non più necessari al sistema.",
- "TaskCleanCache": "Pulisci la directory della cache",
+ "TaskCleanCache": "Pulisci la cartella della cache",
"TasksChannelsCategory": "Canali su Internet",
"TasksApplicationCategory": "Applicazione",
"TasksLibraryCategory": "Libreria",
"TasksMaintenanceCategory": "Manutenzione",
"TaskCleanActivityLog": "Attività di Registro Completate",
- "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata.",
+ "TaskCleanActivityLogDescription": "Elimina le voci del registro delle attività più vecchie dell’età configurata.",
"Undefined": "Non Definito",
"Forced": "Forzato",
"Default": "Predefinito",
- "TaskOptimizeDatabaseDescription": "Compatta Database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altri cambiamenti inerenti il database potrebbe aumentarne la performance.",
- "TaskOptimizeDatabase": "Ottimizza Database",
+ "TaskOptimizeDatabaseDescription": "Compatta database e tronca spazi liberi. Eseguire questa azione dopo la scansione o dopo aver fatto altre modifiche inerenti il database potrebbe aumentarne le prestazioni.",
+ "TaskOptimizeDatabase": "Ottimizza database",
"TaskKeyframeExtractor": "Estrattore di Keyframe",
"TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori playlist HLS. Questa procedura potrebbe richiedere molto tempo.",
"External": "Esterno",
- "HearingImpaired": "con problemi di udito",
+ "HearingImpaired": "Non Udenti",
"TaskRefreshTrickplayImages": "Genera immagini Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Crea anteprime trickplay per i video nelle librerie abilitate."
+ "TaskRefreshTrickplayImagesDescription": "Crea anteprime trickplay per i video nelle librerie abilitate.",
+ "TaskCleanCollectionsAndPlaylists": "Ripulire le collezioni e le playlist",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Rimuove gli elementi dalle collezioni e dalle playlist che non esistono più.",
+ "TaskAudioNormalization": "Normalizzazione dell'audio",
+ "TaskAudioNormalizationDescription": "Scansiona i file alla ricerca dei dati per la normalizzazione dell'audio."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ja.json b/Emby.Server.Implementations/Localization/Core/ja.json
index ab6988006..c8ed7d0fb 100644
--- a/Emby.Server.Implementations/Localization/Core/ja.json
+++ b/Emby.Server.Implementations/Localization/Core/ja.json
@@ -125,5 +125,9 @@
"External": "外部",
"HearingImpaired": "聴覚障害の方",
"TaskRefreshTrickplayImages": "トリックプレー画像を生成",
- "TaskRefreshTrickplayImagesDescription": "有効なライブラリ内のビデオをもとにトリックプレーのプレビューを生成します。"
+ "TaskRefreshTrickplayImagesDescription": "有効なライブラリ内のビデオをもとにトリックプレーのプレビューを生成します。",
+ "TaskCleanCollectionsAndPlaylists": "コレクションとプレイリストをクリーンアップ",
+ "TaskAudioNormalization": "音声の正規化",
+ "TaskAudioNormalizationDescription": "音声の正規化データのためにファイルをスキャンします。",
+ "TaskCleanCollectionsAndPlaylistsDescription": "在しなくなったコレクションやプレイリストからアイテムを削除します。"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ko.json b/Emby.Server.Implementations/Localization/Core/ko.json
index 67dcf5b04..b91889594 100644
--- a/Emby.Server.Implementations/Localization/Core/ko.json
+++ b/Emby.Server.Implementations/Localization/Core/ko.json
@@ -124,5 +124,6 @@
"TaskKeyframeExtractorDescription": "비디오 파일에서 키프레임을 추출하여 더 정확한 HLS 재생 목록을 만듭니다. 이 작업은 오랫동안 진행될 수 있습니다.",
"TaskKeyframeExtractor": "키프레임 추출",
"External": "외부",
- "HearingImpaired": "청각 장애"
+ "HearingImpaired": "청각 장애",
+ "TaskCleanCollectionsAndPlaylists": "컬렉션과 재생목록 정리"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json
index e7279994b..004ce68f5 100644
--- a/Emby.Server.Implementations/Localization/Core/lt-LT.json
+++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json
@@ -126,5 +126,7 @@
"External": "Išorinis",
"HearingImpaired": "Su klausos sutrikimais",
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
- "TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose."
+ "TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.",
+ "TaskCleanCollectionsAndPlaylists": "Sutvarko duomenis jūsų kolekcijose ir grojaraščiuose.",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Pašalina nebeegzistuojančius elementus iš kolekcijų ir grojaraščių."
}
diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json
index 6e58ef834..78c3d0a40 100644
--- a/Emby.Server.Implementations/Localization/Core/lv.json
+++ b/Emby.Server.Implementations/Localization/Core/lv.json
@@ -17,7 +17,7 @@
"Inherit": "Pārmantot",
"AppDeviceValues": "Lietotne: {0}, Ierīce: {1}",
"VersionNumber": "Versija {0}",
- "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai",
+ "ValueHasBeenAddedToLibrary": "{0} tika pievienots jūsu multvides bibliotēkai",
"UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}",
"UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}",
"UserPasswordChangedWithName": "Lietotāja {0} parole tika nomainīta",
@@ -76,7 +76,7 @@
"Genres": "Žanri",
"Folders": "Mapes",
"Favorites": "Izlase",
- "FailedLoginAttemptWithUserName": "Neizdevies ieiešanas mēģinājums no {0}",
+ "FailedLoginAttemptWithUserName": "Neveiksmīgs ielogošanos mēģinājums no {0}",
"DeviceOnlineWithName": "Savienojums ar {0} ir izveidots",
"DeviceOfflineWithName": "Savienojums ar {0} ir pārtraukts",
"Collections": "Kolekcijas",
@@ -95,7 +95,7 @@
"TaskRefreshChapterImages": "Izvilkt nodaļu attēlus",
"TasksApplicationCategory": "Lietotne",
"TasksLibraryCategory": "Bibliotēka",
- "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.",
+ "TaskDownloadMissingSubtitlesDescription": "Meklē trūkstošus subtitrus internēta balstoties uz metadatu uzstādījumiem.",
"TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošos subtitrus",
"TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.",
"TaskRefreshChannels": "Atjaunot kanālus",
@@ -105,8 +105,8 @@
"TaskUpdatePlugins": "Atjaunot paplašinājumus",
"TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.",
"TaskRefreshPeople": "Atjaunot cilvēkus",
- "TaskCleanLogsDescription": "Nodzēš logdatnes, kas ir senākas par {0} dienām.",
- "TaskCleanLogs": "Iztīrīt logdatņu mapi",
+ "TaskCleanLogsDescription": "Nodzēš žurnāla ierakstus, kas ir senāki par {0} dienām.",
+ "TaskCleanLogs": "Iztīrīt žurnālu mapi",
"TaskRefreshLibraryDescription": "Skenē jūsu multivides bibliotēku, lai atrastu jaunas datnes, un atsvaidzina metadatus.",
"TaskRefreshLibrary": "Skenēt multivides bibliotēku",
"TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.",
@@ -125,5 +125,9 @@
"TaskKeyframeExtractor": "Atslēgkadru ekstraktors",
"TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs.",
"TaskRefreshTrickplayImages": "Ģenerēt partīšanas attēlus",
- "TaskRefreshTrickplayImagesDescription": "Izveido priekšskatījumus videoklipu pārtīšanai iespējotajās bibliotēkās."
+ "TaskRefreshTrickplayImagesDescription": "Izveido priekšskatījumus videoklipu pārtīšanai iespējotajās bibliotēkās.",
+ "TaskAudioNormalization": "Audio normalizācija",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Noņem elemēntus no kolekcijām un atskaņošanas sarakstiem, kuri vairs neeksistē.",
+ "TaskAudioNormalizationDescription": "Skanē failus priekš audio normālizācijas informācijas.",
+ "TaskCleanCollectionsAndPlaylists": "Notīrīt kolekcijas un atskaņošanas sarakstus"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json
index 0b50fa529..5c3449381 100644
--- a/Emby.Server.Implementations/Localization/Core/ml.json
+++ b/Emby.Server.Implementations/Localization/Core/ml.json
@@ -6,7 +6,7 @@
"ChapterNameValue": "അധ്യായം {0}",
"DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു",
"DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു",
- "FailedLoginAttemptWithUserName": "{0} - എന്നതിൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു",
+ "FailedLoginAttemptWithUserName": "{0}ൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു",
"Forced": "നിർബന്ധിച്ചു",
"HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ",
"HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ",
@@ -123,5 +123,11 @@
"HearingImpaired": "കേൾവി തകരാറുകൾ",
"External": "പുറമേയുള്ള",
"TaskKeyframeExtractorDescription": "കൂടുതൽ കൃത്യമായ HLS പ്ലേലിസ്റ്റുകൾ സൃഷ്‌ടിക്കുന്നതിന് വീഡിയോ ഫയലുകളിൽ നിന്ന് കീഫ്രെയിമുകൾ എക്‌സ്‌ട്രാക്‌റ്റ് ചെയ്യുന്നു. ഈ പ്രവർത്തനം പൂർത്തിയാവാൻ കുറച്ചധികം സമയം എടുത്തേക്കാം.",
- "TaskKeyframeExtractor": "കീഫ്രെയിം എക്സ്ട്രാക്റ്റർ"
+ "TaskKeyframeExtractor": "കീഫ്രെയിം എക്സ്ട്രാക്റ്റർ",
+ "TaskCleanCollectionsAndPlaylistsDescription": "നിലവിലില്ലാത്ത ശേഖരങ്ങളിൽ നിന്നും പ്ലേലിസ്റ്റുകളിൽ നിന്നും ഇനങ്ങൾ നീക്കംചെയ്യുന്നു.",
+ "TaskCleanCollectionsAndPlaylists": "ശേഖരങ്ങളും പ്ലേലിസ്റ്റുകളും വൃത്തിയാക്കുക",
+ "TaskAudioNormalization": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുക",
+ "TaskAudioNormalizationDescription": "സാധാരണ ശബ്ദ നിലയിലെത്തിലെത്തിക്കുന്ന ഡാറ്റയ്ക്കായി ഫയലുകൾ സ്കാൻ ചെയ്യുക.",
+ "TaskRefreshTrickplayImages": "ട്രിക്ക് പ്ലേ ചിത്രങ്ങൾ സൃഷ്ടിക്കുക",
+ "TaskRefreshTrickplayImagesDescription": "പ്രവർത്തനക്ഷമമാക്കിയ ലൈബ്രറികളിൽ വീഡിയോകൾക്കായി ട്രിക്ക്പ്ലേ പ്രിവ്യൂകൾ സൃഷ്ടിക്കുന്നു."
}
diff --git a/Emby.Server.Implementations/Localization/Core/mt.json b/Emby.Server.Implementations/Localization/Core/mt.json
new file mode 100644
index 000000000..c9e11165d
--- /dev/null
+++ b/Emby.Server.Implementations/Localization/Core/mt.json
@@ -0,0 +1,133 @@
+{
+ "Albums": "Albums",
+ "AppDeviceValues": "App: {0}, Apparat: {1}",
+ "Application": "Applikazzjoni",
+ "Artists": "Artisti",
+ "AuthenticationSucceededWithUserName": "{1} awtentikat b'suċċess",
+ "Books": "Kotba",
+ "CameraImageUploadedFrom": "Ttellgħet immaġni ġdida tal-kamera minn {1}",
+ "Channels": "Kanali",
+ "ChapterNameValue": "Kapitlu {0}",
+ "Collections": "Kollezzjonijiet",
+ "DeviceOfflineWithName": "{0} inqatgħa",
+ "DeviceOnlineWithName": "{0} qabad",
+ "External": "Estern",
+ "FailedLoginAttemptWithUserName": "Tentattiv t'aċċess fallut minn {0}",
+ "Favorites": "Favoriti",
+ "Forced": "Sfurzat",
+ "Genres": "Ġeneri",
+ "HeaderAlbumArtists": "Artisti tal-album",
+ "HeaderContinueWatching": "Kompli Segwi",
+ "HeaderFavoriteAlbums": "Albums Favoriti",
+ "HeaderFavoriteArtists": "Artisti Favoriti",
+ "HeaderFavoriteEpisodes": "Episodji Favoriti",
+ "HeaderFavoriteShows": "Programmi Favoriti",
+ "HeaderFavoriteSongs": "Kanzunetti Favoriti",
+ "HeaderNextUp": "Li Jmiss",
+ "SubtitleDownloadFailureFromForItem": "Is-sottotitli naqsu milli jitniżżlu minn {0} għal {1}",
+ "UserPasswordChangedWithName": "Il-password inbidel għall-utent {0}",
+ "TaskUpdatePluginsDescription": "Iniżżel u jinstalla aġġornamenti għal plugins li huma kkonfigurati biex jaġġornaw awtomatikament.",
+ "TaskDownloadMissingSubtitlesDescription": "Ifittex fuq l-internet għal sottotitli neqsin abbażi tal-konfigurazzjoni tal-metadata.",
+ "TaskOptimizeDatabaseDescription": "Jikkompatti d-database u jaqta' l-ispazju ħieles. It-tħaddim ta' dan il-kompitu wara li tiskennja l-librerija jew tagħmel bidliet oħra li jimplikaw modifiki fid-database jistgħu jtejbu l-prestazzjoni.",
+ "Default": "Standard",
+ "Folders": "Folders",
+ "HeaderLiveTV": "TV Dirett",
+ "HeaderRecordingGroups": "Gruppi ta' Reġistrazzjoni",
+ "HearingImpaired": "Nuqqas ta' Smigħ",
+ "HomeVideos": "Vidjows Personali",
+ "Inherit": "Jiret",
+ "ItemAddedWithName": "{0} ġie miżjud mal-librerija",
+ "ItemRemovedWithName": "{0} tneħħa mil-librerija",
+ "LabelIpAddressValue": "Indirizz IP: {0}",
+ "Latest": "Tal-Aħħar",
+ "MessageApplicationUpdated": "Jellyfin Server ġie aġġornat",
+ "MessageApplicationUpdatedTo": "JellyFin Server ġie aġġornat għal {0}",
+ "MessageNamedServerConfigurationUpdatedWithValue": "Is-sezzjoni {0} tal-konfigurazzjoni tas-server ġiet aġġornata",
+ "MessageServerConfigurationUpdated": "Il-konfigurazzjoni tas-server ġiet aġġornata",
+ "MixedContent": "Kontenut imħallat",
+ "Movies": "Films",
+ "Music": "Mużika",
+ "MusicVideos": "Vidjows tal-Mużika",
+ "NameInstallFailed": "L-installazzjoni ta' {0} falliet",
+ "NameSeasonNumber": "Staġun {0}",
+ "NameSeasonUnknown": "Staġun Mhux Magħruf",
+ "NewVersionIsAvailable": "Verżjoni ġdida ta' Jellyfin Server hija disponibbli biex titniżżel.",
+ "NotificationOptionApplicationUpdateAvailable": "Aġġornament tal-applikazzjoni disponibbli",
+ "NotificationOptionCameraImageUploaded": "Immaġini tal-kamera mtella'",
+ "LabelRunningTimeValue": "Tul: {0}",
+ "NotificationOptionApplicationUpdateInstalled": "Aġġornament tal-applikazzjoni ġie installat",
+ "NotificationOptionAudioPlayback": "Il-playback tal-awdjo beda",
+ "NotificationOptionAudioPlaybackStopped": "Il-playback tal-awdjo twaqqaf",
+ "NotificationOptionInstallationFailed": "Installazzjoni falliet",
+ "NotificationOptionNewLibraryContent": "Kontenut ġdid miżjud",
+ "NotificationOptionPluginError": "Ħsara fil-plugin",
+ "NotificationOptionPluginInstalled": "Plugin installat",
+ "NotificationOptionPluginUninstalled": "Plugin tneħħa",
+ "NotificationOptionServerRestartRequired": "Meħtieġ l-istartjar mill-ġdid tas-server",
+ "NotificationOptionTaskFailed": "Falliment tal-kompitu skedat",
+ "NotificationOptionUserLockedOut": "Utent imsakkar",
+ "Photos": "Ritratti",
+ "Playlists": "Playlists",
+ "Plugin": "Plugin",
+ "PluginInstalledWithName": "{0} ġie installat",
+ "PluginUninstalledWithName": "{0} ġie mneħħi",
+ "PluginUpdatedWithName": "{0} ġie aġġornat",
+ "ProviderValue": "Fornitur: {0}",
+ "ScheduledTaskFailedWithName": "{0} falla",
+ "ScheduledTaskStartedWithName": "{0} beda",
+ "ServerNameNeedsToBeRestarted": "{0} jeħtieġ li jerġa' jinbeda",
+ "Songs": "Kanzunetti",
+ "StartupEmbyServerIsLoading": "Jellyfin Server qed jixgħel. Jekk jogħġbok erġa' pprova dalwaqt.",
+ "Sync": "Sinkronizza",
+ "System": "Sistema",
+ "Undefined": "Mhux Definit",
+ "User": "Utent",
+ "UserCreatedWithName": "L-utent {0} inħoloq",
+ "UserDeletedWithName": "L-utent {0} tħassar",
+ "UserDownloadingItemWithValues": "{0} qed iniżżel {1}",
+ "UserLockedOutWithName": "L-utent {0} ġie msakkar",
+ "UserOfflineFromDevice": "{0} skonnettja minn {1}",
+ "UserOnlineFromDevice": "{0} huwa online minn {1}",
+ "NotificationOptionPluginUpdateInstalled": "Aġġornament ta' plugin ġie installat",
+ "NotificationOptionVideoPlayback": "Il-playback tal-vidjow beda",
+ "NotificationOptionVideoPlaybackStopped": "Il-playback tal-vidjow waqaf",
+ "Shows": "Programmi",
+ "TvShows": "Programmi tat-TV",
+ "UserPolicyUpdatedWithName": "Il-policy tal-utent ġiet aġġornata għal {0}",
+ "UserStartedPlayingItemWithValues": "{0} qed iħaddem {1} fuq {2}",
+ "UserStoppedPlayingItemWithValues": "{0} waqaf iħaddem {1} fuq {2}",
+ "ValueHasBeenAddedToLibrary": "{0} ġie miżjud mal-librerija tal-midja tiegħek",
+ "ValueSpecialEpisodeName": "Speċjali - {0}",
+ "VersionNumber": "Verżjoni {0}",
+ "TasksMaintenanceCategory": "Manutenzjoni",
+ "TasksLibraryCategory": "Librerija",
+ "TasksApplicationCategory": "Applikazzjoni",
+ "TasksChannelsCategory": "Kanali tal-Internet",
+ "TaskCleanActivityLog": "Naddaf il-Logg tal-Attività",
+ "TaskCleanActivityLogDescription": "Iħassar l-entrati tar-reġistru tal-attività eqdem mill-età kkonfigurata.",
+ "TaskCleanCache": "Naddaf id-Direttorju tal-Cache",
+ "TaskCleanCacheDescription": "Iħassar il-fajls tal-cache li m'għadhomx meħtieġa mis-sistema.",
+ "TaskRefreshChapterImages": "Oħroġ l-Immaġini tal-Kapitolu",
+ "TaskRefreshChapterImagesDescription": "Joħloq thumbnails għal vidjows li għandhom kapitli.",
+ "TaskAudioNormalization": "Normalizzazzjoni Awdjo",
+ "TaskAudioNormalizationDescription": "Skennja fajls għal data ta' normalizzazzjoni awdjo.",
+ "TaskRefreshLibrary": "Skennja l-Librerija tal-Midja",
+ "TaskRefreshLibraryDescription": "Jiskennja l-librerija tal-midja tiegħek għal fajls ġodda u jġedded il-metadejta.",
+ "TaskCleanLogs": "Naddaf id-Direttorju tal-Logg",
+ "TaskCleanLogsDescription": "Iħassar fajls tal-logg eqdem minn {0} ijiem.",
+ "TaskRefreshPeople": "Aġġorna Persuni",
+ "TaskRefreshPeopleDescription": "Jaġġorna l-metadejta għall-atturi u d-diretturi fil-librerija tal-midja tiegħek.",
+ "TaskRefreshTrickplayImages": "Iġġenera Stampi Trickplay",
+ "TaskRefreshTrickplayImagesDescription": "Joħloq previews trickplay għal vidjows fil-libreriji attivati.",
+ "TaskUpdatePlugins": "Aġġorna il-Plugins",
+ "TaskCleanTranscode": "Naddaf id-Direttorju tat-Transcode",
+ "TaskCleanTranscodeDescription": "Iħassar fajls transcode eqdem minn ġurnata.",
+ "TaskRefreshChannels": "Aġġorna l-Kanali",
+ "TaskRefreshChannelsDescription": "Aġġorna l-informazzjoni tal-kanali tal-internet.",
+ "TaskDownloadMissingSubtitles": "Niżżel is-sottotitli nieqsa",
+ "TaskOptimizeDatabase": "Ottimizza d-database",
+ "TaskKeyframeExtractor": "Estrattur ta' Keyframes",
+ "TaskKeyframeExtractorDescription": "Jiġbed il-keyframes mill-fajls tal-vidjow biex joħloq playlists HLS aktar preċiżi. Dan il-kompitu jista' jdum għal żmien twil.",
+ "TaskCleanCollectionsAndPlaylists": "Naddaf il-kollezzjonijiet u l-playlists",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Ineħħi oġġetti minn kollezzjonijiet u playlists li m'għadhomx jeżistu."
+}
diff --git a/Emby.Server.Implementations/Localization/Core/my.json b/Emby.Server.Implementations/Localization/Core/my.json
index 198f7540c..4cb4cdc75 100644
--- a/Emby.Server.Implementations/Localization/Core/my.json
+++ b/Emby.Server.Implementations/Localization/Core/my.json
@@ -48,7 +48,7 @@
"Undefined": "သတ်မှတ်မထားသော",
"TvShows": "တီဗီ ဇာတ်လမ်းတွဲများ",
"System": "စနစ်",
- "Sync": "ထပ်တူကျသည်။",
+ "Sync": "ချိန်ကိုက်မည်",
"SubtitleDownloadFailureFromForItem": "{1} အတွက် {0} မှ စာတန်းထိုးများ ဒေါင်းလုဒ်လုပ်ခြင်း မအောင်မြင်ပါ",
"StartupEmbyServerIsLoading": "Jellyfin ဆာဗာကို အသင့်ပြင်နေပါသည်။ ခဏနေ ထပ်စမ်းကြည့်ပါ။",
"Songs": "သီချင်းများ",
@@ -104,7 +104,7 @@
"HeaderFavoriteSongs": "အကြိုက်ဆုံးသီချင်းများ",
"HeaderFavoriteShows": "အကြိုက်ဆုံး ဇာတ်လမ်းတွဲများ",
"HeaderFavoriteEpisodes": "အကြိုက်ဆုံး ဇာတ်လမ်းအပိုင်းများ",
- "HeaderFavoriteArtists": "အကြိုက်ဆုံးအနုပညာရှင်များ",
+ "HeaderFavoriteArtists": "အကြိုက်ဆုံး အနုပညာရှင်များ",
"HeaderFavoriteAlbums": "အကြိုက်ဆုံး အယ်လ်ဘမ်များ",
"HeaderContinueWatching": "ဆက်လက်ကြည့်ရှုပါ",
"HeaderAlbumArtists": "အယ်လ်ဘမ်အနုပညာရှင်များ",
@@ -120,5 +120,11 @@
"AuthenticationSucceededWithUserName": "{0} အောင်မြင်စွာ စစ်မှန်ကြောင်း အတည်ပြုပြီးပါပြီ",
"Application": "အပလီကေးရှင်း",
"AppDeviceValues": "အက်ပ်- {0}၊ စက်- {1}",
- "External": "ပြင်ပ"
+ "External": "ပြင်ပ",
+ "TaskKeyframeExtractorDescription": "ပိုမိုတိကျသည့် အိတ်ချ်အယ်လ်အက်စ် အစဉ်လိုက်ပြသမှုများ ဖန်တီးနိုင်ရန်အတွက် ဗီဒီယိုဖိုင်များမှ ကီးဖရိန်များကို ထုတ်နှုတ်ယူမည် ဖြစ်သည်။ ဤလုပ်ဆောင်မှုသည် အချိန်ကြာရှည်နိုင်သည်။",
+ "TaskCleanCollectionsAndPlaylistsDescription": "စုစည်းမှုများနှင့် အစဉ်လိုက်ပြသမှုများမှ မရှိတော့သည်များကို ဖယ်ရှားမည်။",
+ "TaskRefreshTrickplayImages": "ထရစ်ခ်ပလေး ပုံများကို ထုတ်မည်",
+ "TaskKeyframeExtractor": "ကီးဖရိန်များကို ထုတ်နုတ်ခြင်း",
+ "TaskCleanCollectionsAndPlaylists": "စုစည်းမှုများနှင့် အစဉ်လိုက်ပြသမှုများကို ရှင်းလင်းမည်",
+ "HearingImpaired": "အကြားအာရုံ ချို့တဲ့သူ"
}
diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json
index b6c15d871..b66818ddc 100644
--- a/Emby.Server.Implementations/Localization/Core/nb.json
+++ b/Emby.Server.Implementations/Localization/Core/nb.json
@@ -126,5 +126,9 @@
"External": "Ekstern",
"HearingImpaired": "Hørselshemmet",
"TaskRefreshTrickplayImages": "Generer Trickplay bilder",
- "TaskRefreshTrickplayImagesDescription": "Oppretter trickplay-forhåndsvisninger for videoer i aktiverte biblioteker."
+ "TaskRefreshTrickplayImagesDescription": "Oppretter trickplay-forhåndsvisninger for videoer i aktiverte biblioteker.",
+ "TaskCleanCollectionsAndPlaylists": "Rydd kolleksjoner og spillelister",
+ "TaskAudioNormalization": "Lyd Normalisering",
+ "TaskAudioNormalizationDescription": "Skan filer for lyd normaliserende data",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Fjerner elementer fra kolleksjoner og spillelister som ikke lengere finnes"
}
diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json
index a925b7134..4f076b680 100644
--- a/Emby.Server.Implementations/Localization/Core/nl.json
+++ b/Emby.Server.Implementations/Localization/Core/nl.json
@@ -11,7 +11,7 @@
"Collections": "Collecties",
"DeviceOfflineWithName": "Verbinding met {0} is verbroken",
"DeviceOnlineWithName": "{0} is verbonden",
- "FailedLoginAttemptWithUserName": "Mislukte inlogpoging van {0}",
+ "FailedLoginAttemptWithUserName": "Mislukte aanmeldpoging van {0}",
"Favorites": "Favorieten",
"Folders": "Mappen",
"Genres": "Genres",
@@ -124,7 +124,11 @@
"TaskKeyframeExtractorDescription": "Haalt keyframes uit videobestanden om preciezere HLS-afspeellijsten te maken. Deze taak kan lang duren.",
"TaskKeyframeExtractor": "Keyframes uitpakken",
"External": "Extern",
- "HearingImpaired": "Slechthorend",
+ "HearingImpaired": "Slechthorenden",
"TaskRefreshTrickplayImages": "Trickplay-afbeeldingen genereren",
- "TaskRefreshTrickplayImagesDescription": "Creëert trickplay-voorvertoningen voor video's in bibliotheken waarvoor dit is ingeschakeld."
+ "TaskRefreshTrickplayImagesDescription": "Creëert trickplay-voorvertoningen voor video's in bibliotheken waarvoor dit is ingeschakeld.",
+ "TaskCleanCollectionsAndPlaylists": "Collecties en afspeellijsten opruimen",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Verwijdert niet langer bestaande items uit collecties en afspeellijsten.",
+ "TaskAudioNormalization": "Geluidsnormalisatie",
+ "TaskAudioNormalizationDescription": "Scant bestanden op gegevens voor geluidsnormalisatie."
}
diff --git a/Emby.Server.Implementations/Localization/Core/nn.json b/Emby.Server.Implementations/Localization/Core/nn.json
index d0c914de3..ff6376258 100644
--- a/Emby.Server.Implementations/Localization/Core/nn.json
+++ b/Emby.Server.Implementations/Localization/Core/nn.json
@@ -118,5 +118,6 @@
"Undefined": "Udefinert",
"Forced": "Tvungen",
"Default": "Standard",
- "External": "Ekstern"
+ "External": "Ekstern",
+ "HearingImpaired": "Nedsett høyrsel"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pa.json b/Emby.Server.Implementations/Localization/Core/pa.json
index 1f982feaf..a25099ee0 100644
--- a/Emby.Server.Implementations/Localization/Core/pa.json
+++ b/Emby.Server.Implementations/Localization/Core/pa.json
@@ -104,7 +104,7 @@
"Forced": "ਮਜਬੂਰ",
"Folders": "ਫੋਲਡਰ",
"Favorites": "ਮਨਪਸੰਦ",
- "FailedLoginAttemptWithUserName": "{0} ਤੋਂ ਲਾਗਇਨ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ",
+ "FailedLoginAttemptWithUserName": "{0} ਤੋਂ ਲਾਗਇਨ ਦੀ ਕੋਸ਼ਿਸ਼ ਫੇਲ ਹੋਈ",
"DeviceOnlineWithName": "{0} ਜੁੜਿਆ ਹੋਇਆ ਹੈ",
"DeviceOfflineWithName": "{0} ਡਿਸਕਨੈਕਟ ਹੋ ਗਿਆ ਹੈ",
"Default": "ਡਿਫੌਲਟ",
@@ -119,5 +119,6 @@
"AppDeviceValues": "ਐਪ: {0}, ਜੰਤਰ: {1}",
"Albums": "ਐਲਬਮਾਂ",
"TaskOptimizeDatabase": "ਡਾਟਾਬੇਸ ਅਨੁਕੂਲ ਬਣਾਓ",
- "External": "ਬਾਹਰੀ"
+ "External": "ਬਾਹਰੀ",
+ "HearingImpaired": "ਸੁਨਣ ਵਿਚ ਕਮਜ਼ੋਰ"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json
index bd572b744..f36385be2 100644
--- a/Emby.Server.Implementations/Localization/Core/pl.json
+++ b/Emby.Server.Implementations/Localization/Core/pl.json
@@ -11,7 +11,7 @@
"Collections": "Kolekcje",
"DeviceOfflineWithName": "{0} został rozłączony",
"DeviceOnlineWithName": "{0} połączył się",
- "FailedLoginAttemptWithUserName": "Próba logowania przez {0} zakończona niepowodzeniem",
+ "FailedLoginAttemptWithUserName": "Nieudana próba logowania przez {0}",
"Favorites": "Ulubione",
"Folders": "Foldery",
"Genres": "Gatunki",
@@ -98,8 +98,8 @@
"TaskRefreshChannels": "Odśwież kanały",
"TaskCleanTranscodeDescription": "Usuwa transkodowane pliki starsze niż 1 dzień.",
"TaskCleanTranscode": "Wyczyść folder transkodowania",
- "TaskUpdatePluginsDescription": "Pobiera i instaluje aktualizacje dla pluginów, które są skonfigurowane do automatycznej aktualizacji.",
- "TaskUpdatePlugins": "Aktualizuj pluginy",
+ "TaskUpdatePluginsDescription": "Pobiera i instaluje aktualizacje wtyczek, które są skonfigurowane do automatycznej aktualizacji.",
+ "TaskUpdatePlugins": "Aktualizuj wtyczki",
"TaskRefreshPeopleDescription": "Odświeża metadane o aktorów i reżyserów w Twojej bibliotece mediów.",
"TaskRefreshPeople": "Odśwież obsadę",
"TaskCleanLogsDescription": "Kasuje pliki logów starsze niż {0} dni.",
@@ -126,5 +126,9 @@
"TaskKeyframeExtractor": "Ekstraktor klatek kluczowych",
"HearingImpaired": "Niedosłyszący",
"TaskRefreshTrickplayImages": "Generuj obrazy trickplay",
- "TaskRefreshTrickplayImagesDescription": "Tworzy podglądy trickplay dla filmów we włączonych bibliotekach."
+ "TaskRefreshTrickplayImagesDescription": "Tworzy podglądy trickplay dla filmów we włączonych bibliotekach.",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Usuwa elementy z kolekcji i list odtwarzania, które już nie istnieją.",
+ "TaskCleanCollectionsAndPlaylists": "Oczyść kolekcje i listy odtwarzania",
+ "TaskAudioNormalization": "Normalizacja dźwięku",
+ "TaskAudioNormalizationDescription": "Skanuje pliki w poszukiwaniu danych normalizacji dźwięku."
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json
index 2c8c46050..d9867f5e0 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-BR.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json
@@ -111,7 +111,7 @@
"TaskCleanCacheDescription": "Deletar arquivos temporários que não são mais necessários para o sistema.",
"TaskCleanCache": "Limpar Arquivos Temporários",
"TasksChannelsCategory": "Canais da Internet",
- "TasksApplicationCategory": "Aplicativo",
+ "TasksApplicationCategory": "Aplicação",
"TasksLibraryCategory": "Biblioteca",
"TasksMaintenanceCategory": "Manutenção",
"TaskCleanActivityLogDescription": "Apaga o registro de atividades mais antigo que a idade configurada.",
@@ -126,5 +126,9 @@
"External": "Externo",
"HearingImpaired": "Deficiência Auditiva",
"TaskRefreshTrickplayImages": "Gerar imagens Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Cria prévias Trickplay para vídeos em bibliotecas em que o recurso está habilitado."
+ "TaskRefreshTrickplayImagesDescription": "Cria prévias Trickplay para vídeos em bibliotecas em que o recurso está habilitado.",
+ "TaskCleanCollectionsAndPlaylists": "Limpe coleções e playlists",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e playlists que não existem mais.",
+ "TaskAudioNormalization": "Normalização de áudio",
+ "TaskAudioNormalizationDescription": "Examina os ficheiros em busca de dados de normalização de áudio."
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index 92ac2681e..4f7ef3292 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -126,5 +126,9 @@
"External": "Externo",
"HearingImpaired": "Surdo",
"TaskRefreshTrickplayImages": "Gerar imagens de truques",
- "TaskRefreshTrickplayImagesDescription": "Cria vizualizações de truques para videos nas librarias ativas."
+ "TaskRefreshTrickplayImagesDescription": "Cria vizualizações de truques para videos nas librarias ativas.",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
+ "TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução",
+ "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
+ "TaskAudioNormalization": "Normalização de áudio"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json
index 103393a1e..ff9a0d4f4 100644
--- a/Emby.Server.Implementations/Localization/Core/pt.json
+++ b/Emby.Server.Implementations/Localization/Core/pt.json
@@ -125,5 +125,9 @@
"TaskKeyframeExtractor": "Extrator de quadro-chave",
"TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
"TaskRefreshTrickplayImages": "Gerar miniaturas de vídeo",
- "TaskRefreshTrickplayImagesDescription": "Cria miniaturas de vídeo para vídeos nas bibliotecas definidas."
+ "TaskRefreshTrickplayImagesDescription": "Cria miniaturas de vídeo para vídeos nas bibliotecas definidas.",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
+ "TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução",
+ "TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
+ "TaskAudioNormalization": "Normalização de áudio"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index 537a6d3f2..cd0120fc7 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -78,7 +78,7 @@
"Genres": "Genuri",
"Folders": "Dosare",
"Favorites": "Favorite",
- "FailedLoginAttemptWithUserName": "Încercare de conectare nereușită de la {0}",
+ "FailedLoginAttemptWithUserName": "Încercare de conectare eșuată pentru {0}",
"DeviceOnlineWithName": "{0} este conectat",
"DeviceOfflineWithName": "{0} s-a deconectat",
"Collections": "Colecții",
diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json
index 26d678a0c..3eb1e0468 100644
--- a/Emby.Server.Implementations/Localization/Core/ru.json
+++ b/Emby.Server.Implementations/Localization/Core/ru.json
@@ -11,7 +11,7 @@
"Collections": "Коллекции",
"DeviceOfflineWithName": "{0} - отключено",
"DeviceOnlineWithName": "{0} - подключено",
- "FailedLoginAttemptWithUserName": "{0} - попытка входа неудачна",
+ "FailedLoginAttemptWithUserName": "Неудачная попытка входа с {0}",
"Favorites": "Избранное",
"Folders": "Папки",
"Genres": "Жанры",
@@ -31,7 +31,7 @@
"ItemRemovedWithName": "{0} - изъято из медиатеки",
"LabelIpAddressValue": "IP-адрес: {0}",
"LabelRunningTimeValue": "Длительность: {0}",
- "Latest": "Последние добавленные",
+ "Latest": "Последние",
"MessageApplicationUpdated": "Jellyfin Server был обновлён",
"MessageApplicationUpdatedTo": "Jellyfin Server был обновлён до {0}",
"MessageNamedServerConfigurationUpdatedWithValue": "Конфигурация сервера (раздел {0}) была обновлена",
@@ -126,5 +126,9 @@
"External": "Внешние",
"HearingImpaired": "Для слабослышащих",
"TaskRefreshTrickplayImages": "Сгенерировать изображения для Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Создает предпросмотры для Trickplay для видео в библиотеках, где эта функция включена."
+ "TaskRefreshTrickplayImagesDescription": "Создает предпросмотры для Trickplay для видео в библиотеках, где эта функция включена.",
+ "TaskCleanCollectionsAndPlaylists": "Очистка коллекций и списков воспроизведения",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Удаляет элементы из коллекций и списков воспроизведения, которые больше не существуют.",
+ "TaskAudioNormalization": "Нормализация звука",
+ "TaskAudioNormalizationDescription": "Сканирует файлы на наличие данных о нормализации звука."
}
diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index 43594a42e..a9b6fbeef 100644
--- a/Emby.Server.Implementations/Localization/Core/sk.json
+++ b/Emby.Server.Implementations/Localization/Core/sk.json
@@ -126,5 +126,9 @@
"External": "Externé",
"HearingImpaired": "Sluchovo postihnutí",
"TaskRefreshTrickplayImages": "Generovanie obrázkov Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Vytvára trickplay náhľady pre videá v povolených knižniciach."
+ "TaskRefreshTrickplayImagesDescription": "Vytvára trickplay náhľady pre videá v povolených knižniciach.",
+ "TaskCleanCollectionsAndPlaylists": "Vyčistiť kolekcie a playlisty",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Odstráni položky z kolekcií a playlistov, ktoré už neexistujú.",
+ "TaskAudioNormalization": "Normalizácia zvuku",
+ "TaskAudioNormalizationDescription": "Skenovať súbory za účelom normalizácie zvuku."
}
diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json
index 1fc3cdbaa..f40c4478a 100644
--- a/Emby.Server.Implementations/Localization/Core/sv.json
+++ b/Emby.Server.Implementations/Localization/Core/sv.json
@@ -126,5 +126,9 @@
"External": "Extern",
"HearingImpaired": "Hörselskadad",
"TaskRefreshTrickplayImages": "Generera Trickplay-bilder",
- "TaskRefreshTrickplayImagesDescription": "Skapar trickplay-förhandsvisningar för videor i aktiverade bibliotek."
+ "TaskRefreshTrickplayImagesDescription": "Skapar trickplay-förhandsvisningar för videor i aktiverade bibliotek.",
+ "TaskCleanCollectionsAndPlaylists": "Rensa upp samlingar och spellistor",
+ "TaskAudioNormalization": "Ljudnormalisering",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Tar bort objekt från samlingar och spellistor som inte längre finns.",
+ "TaskAudioNormalizationDescription": "Skannar filer för ljudnormaliseringsdata."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json
index 646d7d7a5..7270d70fc 100644
--- a/Emby.Server.Implementations/Localization/Core/ta.json
+++ b/Emby.Server.Implementations/Localization/Core/ta.json
@@ -125,5 +125,9 @@
"External": "வெளி",
"HearingImpaired": "செவித்திறன் குறைபாடுடையவர்",
"TaskRefreshTrickplayImages": "முன்னோட்ட படங்களை உருவாக்கு",
- "TaskRefreshTrickplayImagesDescription": "செயல்பாட்டில் உள்ள தொகுப்புகளுக்கு முன்னோட்ட படங்களை உருவாக்கும்."
+ "TaskRefreshTrickplayImagesDescription": "செயல்பாட்டில் உள்ள தொகுப்புகளுக்கு முன்னோட்ட படங்களை உருவாக்கும்.",
+ "TaskCleanCollectionsAndPlaylists": "சேகரிப்புகள் மற்றும் பிளேலிஸ்ட்களை சுத்தம் செய்யவும்",
+ "TaskCleanCollectionsAndPlaylistsDescription": "சேகரிப்புகள் மற்றும் பிளேலிஸ்ட்களில் இருந்து உருப்படிகளை நீக்குகிறது.",
+ "TaskAudioNormalization": "ஆடியோ இயல்பாக்கம்",
+ "TaskAudioNormalizationDescription": "ஆடியோ இயல்பாக்குதல் தரவுக்காக கோப்புகளை ஸ்கேன் செய்கிறது."
}
diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json
index 3cdf743d5..da32e9776 100644
--- a/Emby.Server.Implementations/Localization/Core/th.json
+++ b/Emby.Server.Implementations/Localization/Core/th.json
@@ -123,5 +123,7 @@
"External": "ภายนอก",
"HearingImpaired": "บกพร่องทางการได้ยิน",
"TaskKeyframeExtractor": "ตัวแยกคีย์เฟรม",
- "TaskKeyframeExtractorDescription": "แยกคีย์เฟรมจากไฟล์วีดีโอเพื่อสร้างรายการ HLS ให้ถูกต้อง. กระบวนการนี้อาจใช้ระยะเวลานาน"
+ "TaskKeyframeExtractorDescription": "แยกคีย์เฟรมจากไฟล์วีดีโอเพื่อสร้างรายการ HLS ให้ถูกต้อง. กระบวนการนี้อาจใช้ระยะเวลานาน",
+ "TaskRefreshTrickplayImages": "สร้างไฟล์รูปภาพสำหรับ Trickplay",
+ "TaskRefreshTrickplayImagesDescription": "สร้างภาพตัวอย่างของวีดีโอในคลังที่เปิดใช้งาน Trickplay"
}
diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json
index d7a627d12..1dceadc61 100644
--- a/Emby.Server.Implementations/Localization/Core/tr.json
+++ b/Emby.Server.Implementations/Localization/Core/tr.json
@@ -11,7 +11,7 @@
"Collections": "Koleksiyonlar",
"DeviceOfflineWithName": "{0} bağlantısı kesildi",
"DeviceOnlineWithName": "{0} bağlı",
- "FailedLoginAttemptWithUserName": "{0} kullanıcısının giriş denemesi başarısız oldu",
+ "FailedLoginAttemptWithUserName": "{0} kullanıcısının başarısız oturum açma girişimi",
"Favorites": "Favoriler",
"Folders": "Klasörler",
"Genres": "Türler",
@@ -126,5 +126,9 @@
"External": "Harici",
"HearingImpaired": "Duyma Engelli",
"TaskRefreshTrickplayImages": "Trickplay Görselleri Oluştur",
- "TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için trickplay önizlemeleri oluşturur."
+ "TaskRefreshTrickplayImagesDescription": "Etkin kütüphanelerdeki videolar için trickplay önizlemeleri oluşturur.",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Artık var olmayan koleksiyon ve çalma listelerindeki ögeleri kaldırır.",
+ "TaskCleanCollectionsAndPlaylists": "Koleksiyonları ve çalma listelerini temizleyin",
+ "TaskAudioNormalizationDescription": "Ses normalleştirme verileri için dosyaları tarar.",
+ "TaskAudioNormalization": "Ses Normalleştirme"
}
diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json
index 3f7fca427..18073287b 100644
--- a/Emby.Server.Implementations/Localization/Core/uk.json
+++ b/Emby.Server.Implementations/Localization/Core/uk.json
@@ -125,5 +125,9 @@
"External": "Зовнішній",
"HearingImpaired": "З порушеннями слуху",
"TaskRefreshTrickplayImagesDescription": "Створює trickplay-зображення для відео у ввімкнених медіатеках.",
- "TaskRefreshTrickplayImages": "Створити Trickplay-зображення"
+ "TaskRefreshTrickplayImages": "Створити Trickplay-зображення",
+ "TaskCleanCollectionsAndPlaylists": "Очистити колекції і списки відтворення",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Видаляє елементи з колекцій і списків відтворення, які більше не існують.",
+ "TaskAudioNormalizationDescription": "Сканує файли на наявність даних для нормалізації звуку.",
+ "TaskAudioNormalization": "Нормалізація аудіо"
}
diff --git a/Emby.Server.Implementations/Localization/Core/uz.json b/Emby.Server.Implementations/Localization/Core/uz.json
index 43935f224..a1b3035f3 100644
--- a/Emby.Server.Implementations/Localization/Core/uz.json
+++ b/Emby.Server.Implementations/Localization/Core/uz.json
@@ -8,5 +8,20 @@
"Channels": "Kanallar",
"Books": "Kitoblar",
"Artists": "Ijrochilar",
- "Albums": "Albomlar"
+ "Albums": "Albomlar",
+ "AuthenticationSucceededWithUserName": "{0} muvaffaqiyatli tasdiqlandi",
+ "AppDeviceValues": "Ilova: {0}, Qurilma: {1}",
+ "Application": "Ilova",
+ "CameraImageUploadedFrom": "{0}dan yangi kamera rasmi yuklandi",
+ "DeviceOnlineWithName": "{0} ulangan",
+ "ItemRemovedWithName": "{0} kutbxonadan o'chirildi",
+ "External": "Tashqi",
+ "FailedLoginAttemptWithUserName": "Muvafaqiyatsiz kirishlar soni {0}",
+ "Forced": "Majburiy",
+ "ChapterNameValue": "{0}chi bo'lim",
+ "DeviceOfflineWithName": "{0} aloqa uzildi",
+ "HeaderLiveTV": "Jonli TV",
+ "HeaderNextUp": "Keyingisi",
+ "ItemAddedWithName": "{0} kutbxonaga qo'shildi",
+ "LabelIpAddressValue": "IP manzil: {0}"
}
diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json
index e92752c5f..4bedfe3b2 100644
--- a/Emby.Server.Implementations/Localization/Core/vi.json
+++ b/Emby.Server.Implementations/Localization/Core/vi.json
@@ -103,11 +103,11 @@
"HeaderFavoriteEpisodes": "Tập Phim Yêu Thích",
"HeaderFavoriteArtists": "Nghệ Sĩ Yêu Thích",
"HeaderFavoriteAlbums": "Album Ưa Thích",
- "FailedLoginAttemptWithUserName": "Đăng nhập không thành công thử từ {0}",
+ "FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}",
"DeviceOnlineWithName": "{0} đã kết nối",
"DeviceOfflineWithName": "{0} đã ngắt kết nối",
"ChapterNameValue": "Phân Cảnh {0}",
- "Channels": "Các Kênh",
+ "Channels": "Kênh",
"CameraImageUploadedFrom": "Một hình ảnh máy ảnh mới đã được tải lên từ {0}",
"Books": "Sách",
"AuthenticationSucceededWithUserName": "{0} xác thực thành công",
@@ -125,5 +125,9 @@
"External": "Bên ngoài",
"HearingImpaired": "Khiếm Thính",
"TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay",
- "TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật."
+ "TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.",
+ "TaskCleanCollectionsAndPlaylists": "Dọn dẹp bộ sưu tập và danh sách phát",
+ "TaskCleanCollectionsAndPlaylistsDescription": "Xóa các mục khỏi bộ sưu tập và danh sách phát không còn tồn tại.",
+ "TaskAudioNormalization": "Chuẩn Hóa Âm Thanh",
+ "TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh."
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json
index b88d4eeaf..808f73793 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-CN.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json
@@ -11,7 +11,7 @@
"Collections": "合集",
"DeviceOfflineWithName": "{0} 已断开",
"DeviceOnlineWithName": "{0} 已连接",
- "FailedLoginAttemptWithUserName": "从 {0} 尝试登录失败",
+ "FailedLoginAttemptWithUserName": "来自 {0} 的登录尝试失败",
"Favorites": "我的最爱",
"Folders": "文件夹",
"Genres": "类型",
@@ -126,5 +126,9 @@
"External": "外部",
"HearingImpaired": "听力障碍",
"TaskRefreshTrickplayImages": "生成时间轴缩略图",
- "TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成时间轴缩略图。"
+ "TaskRefreshTrickplayImagesDescription": "为启用的媒体库中的视频生成时间轴缩略图。",
+ "TaskCleanCollectionsAndPlaylists": "清理合集和播放列表",
+ "TaskCleanCollectionsAndPlaylistsDescription": "清理合集和播放列表中已不存在的项目。",
+ "TaskAudioNormalization": "音频标准化",
+ "TaskAudioNormalizationDescription": "扫描文件以寻找音频标准化数据。"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index d57a2811d..f06bbc591 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -1,25 +1,25 @@
{
"Albums": "專輯",
- "AppDeviceValues": "App:{0},裝置:{1}",
+ "AppDeviceValues": "應用程式:{0},裝置:{1}",
"Application": "應用程式",
- "Artists": "演出者",
- "AuthenticationSucceededWithUserName": "{0} 成功授權",
- "Books": "圖書",
- "CameraImageUploadedFrom": "{0} 已經成功上傳一張相片",
+ "Artists": "藝人",
+ "AuthenticationSucceededWithUserName": "成功授權 {0}",
+ "Books": "書籍",
+ "CameraImageUploadedFrom": "已從 {0} 成功上傳一張相片",
"Channels": "頻道",
"ChapterNameValue": "章節 {0}",
- "Collections": "合輯",
- "DeviceOfflineWithName": "{0} 已經斷線",
- "DeviceOnlineWithName": "{0} 已經連線",
- "FailedLoginAttemptWithUserName": "來自使用者 {0} 的失敗登入",
+ "Collections": "系列",
+ "DeviceOfflineWithName": "{0} 已中斷連接",
+ "DeviceOnlineWithName": "{0} 已連接",
+ "FailedLoginAttemptWithUserName": "來自使用者 {0} 的登入失敗嘗試",
"Favorites": "我的最愛",
"Folders": "資料夾",
"Genres": "風格",
"HeaderAlbumArtists": "專輯演出者",
- "HeaderContinueWatching": "繼續觀賞",
+ "HeaderContinueWatching": "繼續觀看",
"HeaderFavoriteAlbums": "最愛專輯",
- "HeaderFavoriteArtists": "最愛演出者",
- "HeaderFavoriteEpisodes": "最愛影集",
+ "HeaderFavoriteArtists": "最愛藝人",
+ "HeaderFavoriteEpisodes": "最愛劇集",
"HeaderFavoriteShows": "最愛節目",
"HeaderFavoriteSongs": "最愛歌曲",
"HeaderLiveTV": "電視直播",
@@ -30,8 +30,8 @@
"LabelIpAddressValue": "IP 位址:{0}",
"LabelRunningTimeValue": "運行時間:{0}",
"Latest": "最新",
- "MessageApplicationUpdated": "Jellyfin Server 已經更新",
- "MessageApplicationUpdatedTo": "Jellyfin Server 已經更新至 {0}",
+ "MessageApplicationUpdated": "Jellyfin 伺服器已經更新",
+ "MessageApplicationUpdatedTo": "Jellyfin 伺服器已經更新至 {0}",
"MessageNamedServerConfigurationUpdatedWithValue": "伺服器設定 {0} 部分已經更新",
"MessageServerConfigurationUpdated": "伺服器設定已經更新",
"MixedContent": "混合內容",
@@ -41,7 +41,7 @@
"NameInstallFailed": "{0} 安裝失敗",
"NameSeasonNumber": "第 {0} 季",
"NameSeasonUnknown": "未知季數",
- "NewVersionIsAvailable": "新版本的 Jellyfin Server 已經可供下載。",
+ "NewVersionIsAvailable": "新版本的 Jellyfin 伺服器已經可供下載。",
"NotificationOptionApplicationUpdateAvailable": "有可用的應用程式更新",
"NotificationOptionApplicationUpdateInstalled": "應用程式更新已安裝",
"NotificationOptionAudioPlayback": "音訊播放已開始",
@@ -49,52 +49,52 @@
"NotificationOptionCameraImageUploaded": "相片已上傳",
"NotificationOptionInstallationFailed": "安裝失敗",
"NotificationOptionNewLibraryContent": "已新增新內容",
- "NotificationOptionPluginError": "附加元件安裝失敗",
- "NotificationOptionPluginInstalled": "附加元件已安裝",
- "NotificationOptionPluginUninstalled": "附加元件已移除",
- "NotificationOptionPluginUpdateInstalled": "附加元件已更新",
+ "NotificationOptionPluginError": "擴充功能錯誤",
+ "NotificationOptionPluginInstalled": "擴充功能已安裝",
+ "NotificationOptionPluginUninstalled": "擴充功能已移除",
+ "NotificationOptionPluginUpdateInstalled": "擴充功能已更新",
"NotificationOptionServerRestartRequired": "伺服器需要重新啟動",
- "NotificationOptionTaskFailed": "排程任務失敗",
+ "NotificationOptionTaskFailed": "擴充功能任務失敗",
"NotificationOptionUserLockedOut": "使用者已鎖定",
"NotificationOptionVideoPlayback": "影片播放已開始",
"NotificationOptionVideoPlaybackStopped": "影片播放已停止",
"Photos": "相片",
"Playlists": "播放清單",
- "Plugin": "附加元件",
- "PluginInstalledWithName": "{0} 已安裝",
- "PluginUninstalledWithName": "{0} 已移除",
- "PluginUpdatedWithName": "{0} 已更新",
- "ProviderValue": "提供商: {0}",
- "ScheduledTaskFailedWithName": "排程任務 {0} 已失敗",
+ "Plugin": "擴充功能",
+ "PluginInstalledWithName": "已安裝 {0}",
+ "PluginUninstalledWithName": "已移除 {0}",
+ "PluginUpdatedWithName": "已更新 {0}",
+ "ProviderValue": "提供者:{0}",
+ "ScheduledTaskFailedWithName": "排程任務 {0} 執行失敗",
"ScheduledTaskStartedWithName": "排程任務 {0} 已開始",
"ServerNameNeedsToBeRestarted": "伺服器 {0} 需要重新啟動",
"Shows": "節目",
"Songs": "歌曲",
- "StartupEmbyServerIsLoading": "Jellyfin Server 載入中,請稍後再試。",
+ "StartupEmbyServerIsLoading": "Jellyfin 伺服器載入中,請稍後再試。",
"Sync": "同步",
"System": "系統",
"TvShows": "電視節目",
"User": "使用者",
- "UserCreatedWithName": "使用者 {0} 已建立",
- "UserDeletedWithName": "使用者 {0} 已移除",
+ "UserCreatedWithName": "已建立使用者 {0}",
+ "UserDeletedWithName": "已刪除使用者 {0}",
"UserDownloadingItemWithValues": "使用者 {0} 正在下載 {1}",
- "UserLockedOutWithName": "使用者 {0} 已鎖定",
+ "UserLockedOutWithName": "使用者 {0} 已被鎖定",
"UserOfflineFromDevice": "使用者 {0} 已從 {1} 斷線",
"UserOnlineFromDevice": "使用者 {0} 已從 {1} 連線",
"UserPasswordChangedWithName": "使用者 {0} 的密碼已變更",
- "UserPolicyUpdatedWithName": "使用者協議已更新為 {0}",
- "UserStartedPlayingItemWithValues": "{0}正在 {2} 上播放 {1}",
+ "UserPolicyUpdatedWithName": "使用者權限已更新為 {0}",
+ "UserStartedPlayingItemWithValues": "{0} 正在 {2} 上播放 {1}",
"UserStoppedPlayingItemWithValues": "{0} 已在 {2} 上停止播放 {1}",
"ValueHasBeenAddedToLibrary": "{0} 已新增至您的媒體庫",
"ValueSpecialEpisodeName": "特輯 - {0}",
"VersionNumber": "版本 {0}",
"HeaderRecordingGroups": "錄製組",
"Inherit": "繼承",
- "SubtitleDownloadFailureFromForItem": "無法為 {1} 從 {0} 下載字幕",
+ "SubtitleDownloadFailureFromForItem": "無法從 {0} 下載 {1} 的字幕",
"TaskDownloadMissingSubtitlesDescription": "透過媒體資訊從網路上搜尋遺失的字幕。",
"TaskDownloadMissingSubtitles": "下載遺失的字幕",
"TaskRefreshChannels": "重新整理頻道",
- "TaskUpdatePlugins": "更新附加元件",
+ "TaskUpdatePlugins": "更新擴充功能",
"TaskRefreshPeople": "更新人物",
"TaskCleanLogsDescription": "刪除超過 {0} 天的日誌文件。",
"TaskCleanLogs": "清空日誌資料夾",
@@ -105,9 +105,9 @@
"TaskCleanCache": "清除快取資料夾",
"TasksLibraryCategory": "媒體庫",
"TaskRefreshChannelsDescription": "重新整理網路頻道資料。",
- "TaskCleanTranscodeDescription": "刪除超過一天的轉碼檔案。",
- "TaskCleanTranscode": "清除轉碼資料夾",
- "TaskUpdatePluginsDescription": "為已設置為自動更新的附加元件下載並安裝更新。",
+ "TaskCleanTranscodeDescription": "刪除超過一天的轉檔。",
+ "TaskCleanTranscode": "清除轉檔資料夾",
+ "TaskUpdatePluginsDescription": "下載並更新已啟用自動更新的擴充功能。",
"TaskRefreshPeopleDescription": "更新媒體庫中演員和導演的資訊。",
"TaskRefreshChapterImagesDescription": "為有章節的影片建立縮圖。",
"TasksChannelsCategory": "網路頻道",
@@ -125,5 +125,9 @@
"External": "外部",
"HearingImpaired": "聽力障礙",
"TaskRefreshTrickplayImages": "生成快轉縮圖",
- "TaskRefreshTrickplayImagesDescription": "為啟用此設定的媒體庫生成快轉縮圖。"
+ "TaskRefreshTrickplayImagesDescription": "為啟用快轉縮圖的媒體庫生成快轉縮圖。",
+ "TaskCleanCollectionsAndPlaylists": "清理系列和播放清單",
+ "TaskCleanCollectionsAndPlaylistsDescription": "清理系列和播放清單中已不存在的項目。",
+ "TaskAudioNormalization": "音量標準化",
+ "TaskAudioNormalizationDescription": "掃描文件以找出音量標準化資料。"
}
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 16776b6bd..ac453a5b0 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -278,6 +278,13 @@ namespace Emby.Server.Implementations.Localization
return null;
}
+ // Convert integers directly
+ // This may override some of the locale specific age ratings (but those always map to the same age)
+ if (int.TryParse(rating, out var ratingAge))
+ {
+ return ratingAge;
+ }
+
// Fairly common for some users to have "Rated R" in their rating field
rating = rating.Replace("Rated :", string.Empty, StringComparison.OrdinalIgnoreCase);
rating = rating.Replace("Rated ", string.Empty, StringComparison.OrdinalIgnoreCase);
@@ -314,7 +321,11 @@ namespace Emby.Server.Implementations.Localization
// Try splitting by : to handle "Germany: FSK-18"
if (rating.Contains(':', StringComparison.OrdinalIgnoreCase))
{
- return GetRatingLevel(rating.AsSpan().RightPart(':').ToString());
+ var ratingLevelRightPart = rating.AsSpan().RightPart(':');
+ if (ratingLevelRightPart.Length != 0)
+ {
+ return GetRatingLevel(ratingLevelRightPart.ToString());
+ }
}
// Handle prefix country code to handle "DE-18"
@@ -325,8 +336,12 @@ namespace Emby.Server.Implementations.Localization
// Extract culture from country prefix
var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString());
- // Check rating system of culture
- return GetRatingLevel(ratingSpan.RightPart('-').ToString(), culture?.TwoLetterISOLanguageName);
+ var ratingLevelRightPart = ratingSpan.RightPart('-');
+ if (ratingLevelRightPart.Length != 0)
+ {
+ // Check rating system of culture
+ return GetRatingLevel(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName);
+ }
}
return null;
diff --git a/Emby.Server.Implementations/Localization/Ratings/au.csv b/Emby.Server.Implementations/Localization/Ratings/au.csv
index 688125917..6e12759a4 100644
--- a/Emby.Server.Implementations/Localization/Ratings/au.csv
+++ b/Emby.Server.Implementations/Localization/Ratings/au.csv
@@ -1,11 +1,11 @@
Exempt,0
G,0
7+,7
+PG,15
M,15
MA,15
MA15+,15
MA 15+,15
-PG,16
16+,16
R,18
R18+,18
diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
index aea8d6532..47ff22c0b 100644
--- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs
+++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
@@ -59,68 +59,74 @@ namespace Emby.Server.Implementations.Playlists
_appConfig = appConfig;
}
+ public Playlist GetPlaylistForUser(Guid playlistId, Guid userId)
+ {
+ return GetPlaylists(userId).Where(p => p.Id.Equals(playlistId)).FirstOrDefault();
+ }
+
public IEnumerable<Playlist> GetPlaylists(Guid userId)
{
var user = _userManager.GetUserById(userId);
-
- return GetPlaylistsFolder(userId).GetChildren(user, true).OfType<Playlist>();
+ return _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Playlist],
+ Recursive = true,
+ DtoOptions = new DtoOptions(false)
+ })
+ .Cast<Playlist>()
+ .Where(p => p.IsVisible(user));
}
- public async Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest options)
+ public async Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest request)
{
- var name = options.Name;
+ var name = request.Name;
var folderName = _fileSystem.GetValidFilename(name);
- var parentFolder = GetPlaylistsFolder(options.UserId);
+ var parentFolder = GetPlaylistsFolder(request.UserId);
if (parentFolder is null)
{
throw new ArgumentException(nameof(parentFolder));
}
- if (options.MediaType is null || options.MediaType == MediaType.Unknown)
+ if (request.MediaType is null || request.MediaType == MediaType.Unknown)
{
- foreach (var itemId in options.ItemIdList)
+ foreach (var itemId in request.ItemIdList)
{
- var item = _libraryManager.GetItemById(itemId);
- if (item is null)
- {
- throw new ArgumentException("No item exists with the supplied Id");
- }
-
+ var item = _libraryManager.GetItemById(itemId) ?? throw new ArgumentException("No item exists with the supplied Id");
if (item.MediaType != MediaType.Unknown)
{
- options.MediaType = item.MediaType;
+ request.MediaType = item.MediaType;
}
else if (item is MusicArtist || item is MusicAlbum || item is MusicGenre)
{
- options.MediaType = MediaType.Audio;
+ request.MediaType = MediaType.Audio;
}
else if (item is Genre)
{
- options.MediaType = MediaType.Video;
+ request.MediaType = MediaType.Video;
}
else
{
if (item is Folder folder)
{
- options.MediaType = folder.GetRecursiveChildren(i => !i.IsFolder && i.SupportsAddingToPlaylist)
+ request.MediaType = folder.GetRecursiveChildren(i => !i.IsFolder && i.SupportsAddingToPlaylist)
.Select(i => i.MediaType)
.FirstOrDefault(i => i != MediaType.Unknown);
}
}
- if (options.MediaType is not null && options.MediaType != MediaType.Unknown)
+ if (request.MediaType is not null && request.MediaType != MediaType.Unknown)
{
break;
}
}
}
- if (options.MediaType is null || options.MediaType == MediaType.Unknown)
+ if (request.MediaType is null || request.MediaType == MediaType.Unknown)
{
- options.MediaType = MediaType.Audio;
+ request.MediaType = MediaType.Audio;
}
- var user = _userManager.GetUserById(options.UserId);
+ var user = _userManager.GetUserById(request.UserId);
var path = Path.Combine(parentFolder.Path, folderName);
path = GetTargetPath(path);
@@ -133,19 +139,20 @@ namespace Emby.Server.Implementations.Playlists
{
Name = name,
Path = path,
- OwnerUserId = options.UserId,
- Shares = options.Shares ?? Array.Empty<Share>()
+ OwnerUserId = request.UserId,
+ Shares = request.Users ?? [],
+ OpenAccess = request.Public ?? false
};
- playlist.SetMediaType(options.MediaType);
+ playlist.SetMediaType(request.MediaType);
parentFolder.AddChild(playlist);
await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None)
.ConfigureAwait(false);
- if (options.ItemIdList.Count > 0)
+ if (request.ItemIdList.Count > 0)
{
- await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false)
+ await AddToPlaylistInternal(playlist.Id, request.ItemIdList, user, new DtoOptions(false)
{
EnableImages = true
}).ConfigureAwait(false);
@@ -160,7 +167,19 @@ namespace Emby.Server.Implementations.Playlists
}
}
- private string GetTargetPath(string path)
+ private List<Playlist> GetUserPlaylists(Guid userId)
+ {
+ var user = _userManager.GetUserById(userId);
+ var playlistsFolder = GetPlaylistsFolder(userId);
+ if (playlistsFolder is null)
+ {
+ return [];
+ }
+
+ return playlistsFolder.GetChildren(user, true).OfType<Playlist>().ToList();
+ }
+
+ private static string GetTargetPath(string path)
{
while (Directory.Exists(path))
{
@@ -170,14 +189,14 @@ namespace Emby.Server.Implementations.Playlists
return path;
}
- private List<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, MediaType playlistMediaType, User user, DtoOptions options)
+ private IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, User user, DtoOptions options)
{
- var items = itemIds.Select(i => _libraryManager.GetItemById(i)).Where(i => i is not null);
+ var items = itemIds.Select(_libraryManager.GetItemById).Where(i => i is not null);
- return Playlist.GetPlaylistItems(playlistMediaType, items, user, options);
+ return Playlist.GetPlaylistItems(items, user, options);
}
- public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
+ public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
{
var user = userId.IsEmpty() ? null : _userManager.GetUserById(userId);
@@ -194,7 +213,7 @@ namespace Emby.Server.Implementations.Playlists
?? throw new ArgumentException("No Playlist exists with Id " + playlistId);
// Retrieve all the items to be added to the playlist
- var newItems = GetPlaylistItems(newItemIds, playlist.MediaType, user, options)
+ var newItems = GetPlaylistItems(newItemIds, user, options)
.Where(i => i.SupportsAddingToPlaylist);
// Filter out duplicate items, if necessary
@@ -224,20 +243,10 @@ namespace Emby.Server.Implementations.Playlists
return;
}
- // Create a new array with the updated playlist items
- var newLinkedChildren = new LinkedChild[playlist.LinkedChildren.Length + childrenToAdd.Count];
- playlist.LinkedChildren.CopyTo(newLinkedChildren, 0);
- childrenToAdd.CopyTo(newLinkedChildren, playlist.LinkedChildren.Length);
-
// Update the playlist in the repository
- playlist.LinkedChildren = newLinkedChildren;
- await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+ playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
- // Update the playlist on disk
- if (playlist.IsFile)
- {
- SavePlaylistFile(playlist);
- }
+ await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
// Refresh playlist metadata
_providerManager.QueueRefresh(
@@ -249,7 +258,7 @@ namespace Emby.Server.Implementations.Playlists
RefreshPriority.High);
}
- public async Task RemoveFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds)
+ public async Task RemoveItemFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds)
{
if (_libraryManager.GetItemById(playlistId) is not Playlist playlist)
{
@@ -266,12 +275,7 @@ namespace Emby.Server.Implementations.Playlists
.Select(i => i.Item1)
.ToArray();
- await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
-
- if (playlist.IsFile)
- {
- SavePlaylistFile(playlist);
- }
+ await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
_providerManager.QueueRefresh(
playlist.Id,
@@ -313,14 +317,9 @@ namespace Emby.Server.Implementations.Playlists
newList.Insert(newIndex, item);
}
- playlist.LinkedChildren = newList.ToArray();
+ playlist.LinkedChildren = [.. newList];
- await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
-
- if (playlist.IsFile)
- {
- SavePlaylistFile(playlist);
- }
+ await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
}
/// <inheritdoc />
@@ -430,8 +429,11 @@ namespace Emby.Server.Implementations.Playlists
}
else if (extension.Equals(".m3u8", StringComparison.OrdinalIgnoreCase))
{
- var playlist = new M3uPlaylist();
- playlist.IsExtended = true;
+ var playlist = new M3uPlaylist
+ {
+ IsExtended = true
+ };
+
foreach (var child in item.GetLinkedChildren())
{
var entry = new M3uPlaylistEntry()
@@ -481,7 +483,7 @@ namespace Emby.Server.Implementations.Playlists
}
}
- private string NormalizeItemPath(string playlistPath, string itemPath)
+ private static string NormalizeItemPath(string playlistPath, string itemPath)
{
return MakeRelativePath(Path.GetDirectoryName(playlistPath), itemPath);
}
@@ -516,11 +518,13 @@ namespace Emby.Server.Implementations.Playlists
return relativePath;
}
+ /// <inheritdoc />
public Folder GetPlaylistsFolder()
{
return GetPlaylistsFolder(Guid.Empty);
}
+ /// <inheritdoc />
public Folder GetPlaylistsFolder(Guid userId)
{
const string TypeName = "PlaylistsFolder";
@@ -532,21 +536,16 @@ namespace Emby.Server.Implementations.Playlists
/// <inheritdoc />
public async Task RemovePlaylistsAsync(Guid userId)
{
- var playlists = GetPlaylists(userId);
+ var playlists = GetUserPlaylists(userId);
foreach (var playlist in playlists)
{
// Update owner if shared
- var rankedShares = playlist.Shares.OrderByDescending(x => x.CanEdit).ToArray();
- if (rankedShares.Length > 0 && Guid.TryParse(rankedShares[0].UserId, out var guid))
+ var rankedShares = playlist.Shares.OrderByDescending(x => x.CanEdit).ToList();
+ if (rankedShares.Count > 0)
{
- playlist.OwnerUserId = guid;
+ playlist.OwnerUserId = rankedShares[0].UserId;
playlist.Shares = rankedShares.Skip(1).ToArray();
- await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
-
- if (playlist.IsFile)
- {
- SavePlaylistFile(playlist);
- }
+ await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
}
else if (!playlist.OpenAccess)
{
@@ -563,5 +562,76 @@ namespace Emby.Server.Implementations.Playlists
}
}
}
+
+ public async Task UpdatePlaylist(PlaylistUpdateRequest request)
+ {
+ var playlist = GetPlaylistForUser(request.Id, request.UserId);
+
+ if (request.Ids is not null)
+ {
+ playlist.LinkedChildren = [];
+ await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
+
+ var user = _userManager.GetUserById(request.UserId);
+ await AddToPlaylistInternal(request.Id, request.Ids, user, new DtoOptions(false)
+ {
+ EnableImages = true
+ }).ConfigureAwait(false);
+
+ playlist = GetPlaylistForUser(request.Id, request.UserId);
+ }
+
+ if (request.Name is not null)
+ {
+ playlist.Name = request.Name;
+ }
+
+ if (request.Users is not null)
+ {
+ playlist.Shares = request.Users;
+ }
+
+ if (request.Public is not null)
+ {
+ playlist.OpenAccess = request.Public.Value;
+ }
+
+ await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
+ }
+
+ public async Task AddUserToShares(PlaylistUserUpdateRequest request)
+ {
+ var userId = request.UserId;
+ var playlist = GetPlaylistForUser(request.Id, userId);
+ var shares = playlist.Shares.ToList();
+ var existingUserShare = shares.FirstOrDefault(s => s.UserId.Equals(userId));
+ if (existingUserShare is not null)
+ {
+ shares.Remove(existingUserShare);
+ }
+
+ shares.Add(new PlaylistUserPermissions(userId, request.CanEdit ?? false));
+ playlist.Shares = shares;
+ await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
+ }
+
+ public async Task RemoveUserFromShares(Guid playlistId, Guid userId, PlaylistUserPermissions share)
+ {
+ var playlist = GetPlaylistForUser(playlistId, userId);
+ var shares = playlist.Shares.ToList();
+ shares.Remove(share);
+ playlist.Shares = shares;
+ await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
+ }
+
+ private async Task UpdatePlaylistInternal(Playlist playlist)
+ {
+ await playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+
+ if (playlist.IsFile)
+ {
+ SavePlaylistFile(playlist);
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
index efb6436ae..40e1bbf15 100644
--- a/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
@@ -256,8 +256,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
{
get
{
- var triggers = InternalTriggers;
- return triggers.Select(i => i.Item1).ToArray();
+ return Array.ConvertAll(InternalTriggers, i => i.Item1);
}
set
@@ -269,7 +268,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
SaveTriggers(triggerList);
- InternalTriggers = triggerList.Select(i => new Tuple<TaskTriggerInfo, ITaskTrigger>(i, GetTrigger(i))).ToArray();
+ InternalTriggers = Array.ConvertAll(triggerList, i => new Tuple<TaskTriggerInfo, ITaskTrigger>(i, GetTrigger(i)));
}
}
@@ -503,7 +502,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
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).ToArray();
+ var settings = LoadTriggerSettings().Where(i => i is not null);
return settings.Select(i => new Tuple<TaskTriggerInfo, ITaskTrigger>(i, GetTrigger(i))).ToArray();
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
new file mode 100644
index 000000000..301c04915
--- /dev/null
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
+using Jellyfin.Extensions;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
+
+/// <summary>
+/// The audio normalization task.
+/// </summary>
+public partial class AudioNormalizationTask : IScheduledTask
+{
+ private readonly IItemRepository _itemRepository;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IMediaEncoder _mediaEncoder;
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly ILocalizationManager _localization;
+ private readonly ILogger<AudioNormalizationTask> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AudioNormalizationTask"/> class.
+ /// </summary>
+ /// <param name="itemRepository">Instance of the <see cref="IItemRepository"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
+ /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
+ /// <param name="localizationManager">Instance of the <see cref="ILocalizationManager"/> interface.</param>
+ /// <param name="logger">Instance of the <see cref="ILogger{AudioNormalizationTask}"/> interface.</param>
+ public AudioNormalizationTask(
+ IItemRepository itemRepository,
+ ILibraryManager libraryManager,
+ IMediaEncoder mediaEncoder,
+ IApplicationPaths applicationPaths,
+ ILocalizationManager localizationManager,
+ ILogger<AudioNormalizationTask> logger)
+ {
+ _itemRepository = itemRepository;
+ _libraryManager = libraryManager;
+ _mediaEncoder = mediaEncoder;
+ _applicationPaths = applicationPaths;
+ _localization = localizationManager;
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskAudioNormalization");
+
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskAudioNormalizationDescription");
+
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksLibraryCategory");
+
+ /// <inheritdoc />
+ public string Key => "AudioNormalization";
+
+ [GeneratedRegex(@"^\s+I:\s+(.*?)\s+LUFS")]
+ private static partial Regex LUFSRegex();
+
+ /// <inheritdoc />
+ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ foreach (var library in _libraryManager.RootFolder.Children)
+ {
+ var libraryOptions = _libraryManager.GetLibraryOptions(library);
+ if (!libraryOptions.EnableLUFSScan)
+ {
+ continue;
+ }
+
+ // Album gain
+ var albums = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.MusicAlbum],
+ Parent = library,
+ Recursive = true
+ });
+
+ foreach (var a in albums)
+ {
+ if (a.NormalizationGain.HasValue || a.LUFS.HasValue)
+ {
+ continue;
+ }
+
+ // 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;
+ }
+
+ _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),
+ cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ File.Delete(tempFile);
+ }
+ }
+
+ _itemRepository.SaveItems(albums, cancellationToken);
+
+ // Track gain
+ var tracks = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ MediaTypes = [MediaType.Audio],
+ IncludeItemTypes = [BaseItemKind.Audio],
+ Parent = library,
+ Recursive = true
+ });
+
+ foreach (var t in tracks)
+ {
+ 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)), cancellationToken);
+ }
+
+ _itemRepository.SaveItems(tracks, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return
+ [
+ new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfo.TriggerInterval,
+ IntervalTicks = TimeSpan.FromHours(24).Ticks
+ }
+ ];
+ }
+
+ private async Task<float?> CalculateLUFSAsync(string inputArgs, CancellationToken cancellationToken)
+ {
+ var args = $"-hide_banner {inputArgs} -af ebur128=framelog=verbose -f null -";
+
+ using (var process = new Process()
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = _mediaEncoder.EncoderPath,
+ Arguments = args,
+ RedirectStandardOutput = false,
+ RedirectStandardError = true
+ },
+ })
+ {
+ try
+ {
+ _logger.LogDebug("Starting ffmpeg with arguments: {Arguments}", args);
+ process.Start();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error starting ffmpeg with arguments: {Arguments}", args);
+ return null;
+ }
+
+ using var reader = process.StandardError;
+ await foreach (var line in reader.ReadAllLinesAsync(cancellationToken))
+ {
+ Match match = LUFSRegex().Match(line);
+
+ if (match.Success)
+ {
+ return float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
+ }
+ }
+
+ _logger.LogError("Failed to find LUFS value in output");
+ return null;
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
index d03d40863..36456504b 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
@@ -13,7 +13,6 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs
index 812df8192..804097219 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs
@@ -116,7 +116,7 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
foreach (var linkedChild in folder.LinkedChildren)
{
var path = linkedChild.Path;
- if (!File.Exists(path))
+ if (!File.Exists(path) && !Directory.Exists(path))
{
_logger.LogInformation("Item in {FolderName} cannot be found at {ItemPath}", folder.Name, path);
(itemsToRemove ??= new List<LinkedChild>()).Add(linkedChild);
@@ -127,15 +127,8 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
{
_logger.LogDebug("Updating {FolderName}", folder.Name);
folder.LinkedChildren = folder.LinkedChildren.Except(itemsToRemove).ToArray();
+ _providerManager.SaveMetadataAsync(folder, ItemUpdateType.MetadataEdit);
folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken);
-
- _providerManager.QueueRefresh(
- folder.Id,
- new MetadataRefreshOptions(new DirectoryService(_fileSystem))
- {
- ForceSave = true
- },
- RefreshPriority.High);
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs
index 03935b384..fc3ad90f6 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteCacheFileTask.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
@@ -133,53 +134,14 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
cancellationToken.ThrowIfCancellationRequested();
- DeleteFile(file.FullName);
+ FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
index++;
}
- DeleteEmptyFolders(directory);
+ FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
progress.Report(100);
}
-
- private void DeleteEmptyFolders(string parent)
- {
- foreach (var directory in _fileSystem.GetDirectoryPaths(parent))
- {
- DeleteEmptyFolders(directory);
- if (!_fileSystem.GetFileSystemEntryPaths(directory).Any())
- {
- try
- {
- Directory.Delete(directory, false);
- }
- catch (UnauthorizedAccessException ex)
- {
- _logger.LogError(ex, "Error deleting directory {Path}", directory);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting directory {Path}", directory);
- }
- }
- }
- }
-
- private void DeleteFile(string path)
- {
- try
- {
- _fileSystem.DeleteFile(path);
- }
- catch (UnauthorizedAccessException ex)
- {
- _logger.LogError(ex, "Error deleting file {Path}", path);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting file {Path}", path);
- }
- }
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
index e4e565c64..254500ccd 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
-using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
@@ -62,16 +62,17 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
/// <inheritdoc />
public bool IsLogged => true;
- /// <summary>
- /// Creates the triggers that define when the task will run.
- /// </summary>
- /// <returns>IEnumerable{BaseTaskTrigger}.</returns>
+ /// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
+ Type = TaskTriggerInfo.TriggerStartup
+ },
+ new TaskTriggerInfo
+ {
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(24).Ticks
}
@@ -113,53 +114,14 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
cancellationToken.ThrowIfCancellationRequested();
- DeleteFile(file.FullName);
+ FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
index++;
}
- DeleteEmptyFolders(directory);
+ FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
progress.Report(100);
}
-
- private void DeleteEmptyFolders(string parent)
- {
- foreach (var directory in _fileSystem.GetDirectoryPaths(parent))
- {
- DeleteEmptyFolders(directory);
- if (!_fileSystem.GetFileSystemEntryPaths(directory).Any())
- {
- try
- {
- Directory.Delete(directory, false);
- }
- catch (UnauthorizedAccessException ex)
- {
- _logger.LogError(ex, "Error deleting directory {Path}", directory);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting directory {Path}", directory);
- }
- }
- }
- }
-
- private void DeleteFile(string path)
- {
- try
- {
- _fileSystem.DeleteFile(path);
- }
- catch (UnauthorizedAccessException ex)
- {
- _logger.LogError(ex, "Error deleting file {Path}", path);
- }
- catch (IOException ex)
- {
- _logger.LogError(ex, "Error deleting file {Path}", path);
- }
- }
}
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs
index d65ac2e5e..9425b47d0 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Triggers/IntervalTrigger.cs
@@ -27,45 +27,31 @@ namespace Emby.Server.Implementations.ScheduledTasks.Triggers
TaskOptions = taskOptions;
}
- /// <summary>
- /// Occurs when [triggered].
- /// </summary>
+ /// <inheritdoc />
public event EventHandler<EventArgs>? Triggered;
- /// <summary>
- /// Gets the options of this task.
- /// </summary>
+ /// <inheritdoc />
public TaskOptions TaskOptions { get; }
- /// <summary>
- /// Stars waiting for the trigger action.
- /// </summary>
- /// <param name="lastResult">The last result.</param>
- /// <param name="logger">The logger.</param>
- /// <param name="taskName">The name of the task.</param>
- /// <param name="isApplicationStartup">if set to <c>true</c> [is application startup].</param>
+ /// <inheritdoc />
public void Start(TaskResult? lastResult, ILogger logger, string taskName, bool isApplicationStartup)
{
DisposeTimer();
+ DateTime now = DateTime.UtcNow;
DateTime triggerDate;
if (lastResult is null)
{
// Task has never been completed before
- triggerDate = DateTime.UtcNow.AddHours(1);
+ triggerDate = now.AddHours(1);
}
else
{
- triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate }.Max().Add(_interval);
- }
-
- if (DateTime.UtcNow > triggerDate)
- {
- triggerDate = DateTime.UtcNow.AddMinutes(1);
+ triggerDate = new[] { lastResult.EndTimeUtc, _lastStartDate, now.AddMinutes(1) }.Max().Add(_interval);
}
- var dueTime = triggerDate - DateTime.UtcNow;
+ var dueTime = triggerDate - now;
var maxDueTime = TimeSpan.FromDays(7);
if (dueTime > maxDueTime)
@@ -76,9 +62,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Triggers
_timer = new Timer(_ => OnTriggered(), null, dueTime, TimeSpan.FromMilliseconds(-1));
}
- /// <summary>
- /// Stops waiting for the trigger action.
- /// </summary>
+ /// <inheritdoc />
public void Stop()
{
DisposeTimer();
diff --git a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs
index 1bac2600c..aa5fbbdf7 100644
--- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs
+++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs
@@ -15,10 +15,9 @@ namespace Emby.Server.Implementations.Serialization
{
// Need to cache these
// http://dotnetcodebox.blogspot.com/2013/01/xmlserializer-class-may-result-in.html
- private static readonly ConcurrentDictionary<string, XmlSerializer> _serializers =
- new ConcurrentDictionary<string, XmlSerializer>();
+ private readonly ConcurrentDictionary<string, XmlSerializer> _serializers = new();
- private static XmlSerializer GetSerializer(Type type)
+ private XmlSerializer GetSerializer(Type type)
=> _serializers.GetOrAdd(
type.FullName ?? throw new ArgumentException($"Invalid type {type}."),
static (_, t) => new XmlSerializer(t),
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 75945b08a..3dda5fdee 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -159,10 +159,7 @@ namespace Emby.Server.Implementations.Session
private void CheckDisposed()
{
- if (_disposed)
- {
- throw new ObjectDisposedException(GetType().Name);
- }
+ ObjectDisposedException.ThrowIf(_disposed, this);
}
private void OnSessionStarted(SessionInfo info)
@@ -403,7 +400,7 @@ namespace Emby.Server.Implementations.Session
{
session.NowPlayingQueue = nowPlayingQueue;
- var itemIds = nowPlayingQueue.Select(queue => queue.Id).ToArray();
+ var itemIds = Array.ConvertAll(nowPlayingQueue, queue => queue.Id);
session.NowPlayingQueueFullItems = _dtoService.GetBaseItemDtos(
_libraryManager.GetItemList(new InternalItemsQuery { ItemIds = itemIds }),
new DtoOptions(true));
@@ -1205,7 +1202,8 @@ namespace Emby.Server.Implementations.Session
new DtoOptions(false)
{
EnableImages = false
- })
+ },
+ user.DisplayMissingEpisodes)
.Where(i => !i.IsVirtualItem)
.SkipWhile(i => !i.Id.Equals(episode.Id))
.ToList();
@@ -1389,16 +1387,13 @@ namespace Emby.Server.Implementations.Session
if (session.AdditionalUsers.All(i => !i.UserId.Equals(userId)))
{
var user = _userManager.GetUserById(userId);
-
- var list = session.AdditionalUsers.ToList();
-
- list.Add(new SessionUserInfo
+ var newUser = new SessionUserInfo
{
UserId = userId,
UserName = user.Username
- });
+ };
- session.AdditionalUsers = list.ToArray();
+ session.AdditionalUsers = [..session.AdditionalUsers, newUser];
}
}
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index b3c93a904..aba51de8f 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -34,11 +34,6 @@ namespace Emby.Server.Implementations.Session
private const float ForceKeepAliveFactor = 0.75f;
/// <summary>
- /// Lock used for accessing the KeepAlive cancellation token.
- /// </summary>
- private readonly object _keepAliveLock = new object();
-
- /// <summary>
/// The WebSocket watchlist.
/// </summary>
private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>();
@@ -55,7 +50,7 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// The KeepAlive cancellation token.
/// </summary>
- private CancellationTokenSource? _keepAliveCancellationToken;
+ private System.Timers.Timer _keepAlive;
/// <summary>
/// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
@@ -71,12 +66,34 @@ namespace Emby.Server.Implementations.Session
_logger = logger;
_sessionManager = sessionManager;
_loggerFactory = loggerFactory;
+ _keepAlive = new System.Timers.Timer(TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor))
+ {
+ AutoReset = true,
+ Enabled = false
+ };
+ _keepAlive.Elapsed += KeepAliveSockets;
}
/// <inheritdoc />
public void Dispose()
{
- StopKeepAlive();
+ if (_keepAlive is not null)
+ {
+ _keepAlive.Stop();
+ _keepAlive.Elapsed -= KeepAliveSockets;
+ _keepAlive.Dispose();
+ _keepAlive = null!;
+ }
+
+ lock (_webSocketsLock)
+ {
+ foreach (var webSocket in _webSockets)
+ {
+ webSocket.Closed -= OnWebSocketClosed;
+ }
+
+ _webSockets.Clear();
+ }
}
/// <summary>
@@ -164,7 +181,7 @@ namespace Emby.Server.Implementations.Session
webSocket.Closed += OnWebSocketClosed;
webSocket.LastKeepAliveDate = DateTime.UtcNow;
- StartKeepAlive();
+ _keepAlive.Start();
}
// Notify WebSocket about timeout
@@ -186,66 +203,26 @@ namespace Emby.Server.Implementations.Session
{
lock (_webSocketsLock)
{
- if (!_webSockets.Remove(webSocket))
- {
- _logger.LogWarning("WebSocket {0} not on watchlist.", webSocket);
- }
- else
+ if (_webSockets.Remove(webSocket))
{
webSocket.Closed -= OnWebSocketClosed;
}
- }
- }
-
- /// <summary>
- /// Starts the KeepAlive watcher.
- /// </summary>
- private void StartKeepAlive()
- {
- lock (_keepAliveLock)
- {
- if (_keepAliveCancellationToken is null)
- {
- _keepAliveCancellationToken = new CancellationTokenSource();
- // Start KeepAlive watcher
- _ = RepeatAsyncCallbackEvery(
- KeepAliveSockets,
- TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor),
- _keepAliveCancellationToken.Token);
- }
- }
- }
-
- /// <summary>
- /// Stops the KeepAlive watcher.
- /// </summary>
- private void StopKeepAlive()
- {
- lock (_keepAliveLock)
- {
- if (_keepAliveCancellationToken is not null)
+ else
{
- _keepAliveCancellationToken.Cancel();
- _keepAliveCancellationToken.Dispose();
- _keepAliveCancellationToken = null;
+ _logger.LogWarning("WebSocket {0} not on watchlist.", webSocket);
}
- }
- lock (_webSocketsLock)
- {
- foreach (var webSocket in _webSockets)
+ if (_webSockets.Count == 0)
{
- webSocket.Closed -= OnWebSocketClosed;
+ _keepAlive.Stop();
}
-
- _webSockets.Clear();
}
}
/// <summary>
/// Checks status of KeepAlive of WebSockets.
/// </summary>
- private async Task KeepAliveSockets()
+ private async void KeepAliveSockets(object? o, EventArgs? e)
{
List<IWebSocketConnection> inactive;
List<IWebSocketConnection> lost;
@@ -291,11 +268,6 @@ namespace Emby.Server.Implementations.Session
RemoveWebSocket(webSocket);
}
}
-
- if (_webSockets.Count == 0)
- {
- StopKeepAlive();
- }
}
}
@@ -310,29 +282,5 @@ namespace Emby.Server.Implementations.Session
new ForceKeepAliveMessage(WebSocketLostTimeout),
CancellationToken.None);
}
-
- /// <summary>
- /// Runs a given async callback once every specified interval time, until cancelled.
- /// </summary>
- /// <param name="callback">The async callback.</param>
- /// <param name="interval">The interval time.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task.</returns>
- private async Task RepeatAsyncCallbackEvery(Func<Task> callback, TimeSpan interval, CancellationToken cancellationToken)
- {
- while (!cancellationToken.IsCancellationRequested)
- {
- await callback().ConfigureAwait(false);
-
- try
- {
- await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
- }
- catch (TaskCanceledException)
- {
- return;
- }
- }
- }
}
}
diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs
index 34c9e86f2..c1a615666 100644
--- a/Emby.Server.Implementations/TV/TVSeriesManager.cs
+++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs
@@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.TV
}
string? presentationUniqueKey = null;
- int? limit = null;
+ int? limit = request.Limit;
if (!request.SeriesId.IsNullOrEmpty())
{
if (_libraryManager.GetItemById(request.SeriesId.Value) is Series series)
diff --git a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs
index 965b7e7e6..e425000cd 100644
--- a/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs
+++ b/Jellyfin.Api/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandler.cs
@@ -1,10 +1,7 @@
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
-using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
@@ -15,61 +12,44 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
public class FirstTimeSetupHandler : AuthorizationHandler<FirstTimeSetupRequirement>
{
private readonly IConfigurationManager _configurationManager;
- private readonly IUserManager _userManager;
/// <summary>
/// Initializes a new instance of the <see cref="FirstTimeSetupHandler" /> class.
/// </summary>
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
- /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
- public FirstTimeSetupHandler(
- IConfigurationManager configurationManager,
- IUserManager userManager)
+ public FirstTimeSetupHandler(IConfigurationManager configurationManager)
{
_configurationManager = configurationManager;
- _userManager = userManager;
}
/// <inheritdoc />
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupRequirement requirement)
{
+ // Succeed if the startup wizard / first time setup is not complete
if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
{
context.Succeed(requirement);
- return Task.CompletedTask;
}
- var contextUser = context.User;
- if (requirement.RequireAdmin && !contextUser.IsInRole(UserRoles.Administrator))
- {
- context.Fail();
- return Task.CompletedTask;
- }
-
- var userId = contextUser.GetUserId();
- if (userId.IsEmpty())
- {
- context.Fail();
- return Task.CompletedTask;
- }
-
- if (!requirement.ValidateParentalSchedule)
+ // Succeed if user is admin
+ else if (context.User.IsInRole(UserRoles.Administrator))
{
context.Succeed(requirement);
- return Task.CompletedTask;
}
- var user = _userManager.GetUserById(userId);
- if (user is null)
+ // Fail if admin is required and user is not admin
+ else if (requirement.RequireAdmin)
{
- throw new ResourceNotFoundException();
+ context.Fail();
}
- if (user.IsParentalScheduleAllowed())
+ // Succeed if admin is not required and user is not guest
+ else if (context.User.IsInRole(UserRoles.User))
{
context.Succeed(requirement);
}
+ // Any user-specific checks are handled in the DefaultAuthorizationHandler.
return Task.CompletedTask;
}
}
diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs
index 72be55513..8954c8ef5 100644
--- a/Jellyfin.Api/Controllers/AudioController.cs
+++ b/Jellyfin.Api/Controllers/AudioController.cs
@@ -83,6 +83,7 @@ public class AudioController : BaseJellyfinApiController
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
/// <param name="streamOptions">Optional. The streaming options.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Audio stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("{itemId}/stream", Name = "GetAudioStream")]
@@ -138,7 +139,8 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
- [FromQuery] Dictionary<string, string>? streamOptions)
+ [FromQuery] Dictionary<string, string>? streamOptions,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
StreamingRequestDto streamingRequest = new StreamingRequestDto
{
@@ -189,7 +191,8 @@ public class AudioController : BaseJellyfinApiController
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context ?? EncodingContext.Static,
- StreamOptions = streamOptions
+ StreamOptions = streamOptions,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
@@ -247,6 +250,7 @@ public class AudioController : BaseJellyfinApiController
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
/// <param name="streamOptions">Optional. The streaming options.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Audio stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")]
@@ -302,7 +306,8 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
- [FromQuery] Dictionary<string, string>? streamOptions)
+ [FromQuery] Dictionary<string, string>? streamOptions,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
StreamingRequestDto streamingRequest = new StreamingRequestDto
{
@@ -353,7 +358,8 @@ public class AudioController : BaseJellyfinApiController
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context ?? EncodingContext.Static,
- StreamOptions = streamOptions
+ StreamOptions = streamOptions,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs
index 076084c7a..ee912a9be 100644
--- a/Jellyfin.Api/Controllers/DashboardController.cs
+++ b/Jellyfin.Api/Controllers/DashboardController.cs
@@ -5,6 +5,7 @@ using System.Linq;
using System.Net.Mime;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Models;
+using MediaBrowser.Common.Api;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Net;
using MediaBrowser.Model.Plugins;
@@ -45,9 +46,9 @@ public class DashboardController : BaseJellyfinApiController
/// <response code="404">Server still loading.</response>
/// <returns>An <see cref="IEnumerable{ConfigurationPageInfo}"/> with infos about the plugins.</returns>
[HttpGet("web/ConfigurationPages")]
+ [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- [Authorize]
public ActionResult<IEnumerable<ConfigurationPageInfo>> GetConfigurationPages(
[FromQuery] bool? enableInMainMenu)
{
diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
index 1cad66326..6d94d96f3 100644
--- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
+++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
@@ -194,7 +194,7 @@ public class DisplayPreferencesController : BaseJellyfinApiController
foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
{
- if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out var type))
+ if (!Enum.TryParse<ViewType>(displayPreferences.CustomPrefs[key], true, out _))
{
_logger.LogError("Invalid ViewType: {LandingScreenOption}", displayPreferences.CustomPrefs[key]);
displayPreferences.CustomPrefs.Remove(key);
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index 49fc2f3d7..329dd2c4c 100644
--- a/Jellyfin.Api/Controllers/DynamicHlsController.cs
+++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs
@@ -156,6 +156,7 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="maxWidth">Optional. The max width.</param>
/// <param name="maxHeight">Optional. The max height.</param>
/// <param name="enableSubtitlesInManifest">Optional. Whether to enable subtitles in the manifest.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Hls live stream retrieved.</response>
/// <returns>A <see cref="FileResult"/> containing the hls file.</returns>
[HttpGet("Videos/{itemId}/live.m3u8")]
@@ -213,7 +214,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] Dictionary<string, string> streamOptions,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
- [FromQuery] bool? enableSubtitlesInManifest)
+ [FromQuery] bool? enableSubtitlesInManifest,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
VideoRequestDto streamingRequest = new VideoRequestDto
{
@@ -267,7 +269,8 @@ public class DynamicHlsController : BaseJellyfinApiController
StreamOptions = streamOptions,
MaxHeight = maxHeight,
MaxWidth = maxWidth,
- EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true
+ EnableSubtitlesInManifest = enableSubtitlesInManifest ?? true,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
// CTS lifecycle is managed internally.
@@ -393,6 +396,7 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="streamOptions">Optional. The streaming options.</param>
/// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
/// <param name="enableTrickplay">Enable trickplay image playlists being added to master playlist.</param>
+ /// <param name="enableAudioVbrEncoding">Whether to enable Audio Encoding.</param>
/// <response code="200">Video stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
[HttpGet("Videos/{itemId}/master.m3u8")]
@@ -451,7 +455,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] EncodingContext? context,
[FromQuery] Dictionary<string, string> streamOptions,
[FromQuery] bool enableAdaptiveBitrateStreaming = true,
- [FromQuery] bool enableTrickplay = true)
+ [FromQuery] bool enableTrickplay = true,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
var streamingRequest = new HlsVideoRequestDto
{
@@ -505,7 +510,8 @@ public class DynamicHlsController : BaseJellyfinApiController
Context = context ?? EncodingContext.Streaming,
StreamOptions = streamOptions,
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming,
- EnableTrickplay = enableTrickplay
+ EnableTrickplay = enableTrickplay,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
@@ -564,6 +570,7 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
/// <param name="streamOptions">Optional. The streaming options.</param>
/// <param name="enableAdaptiveBitrateStreaming">Enable adaptive bitrate streaming.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Audio stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the playlist file.</returns>
[HttpGet("Audio/{itemId}/master.m3u8")]
@@ -620,7 +627,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
[FromQuery] Dictionary<string, string> streamOptions,
- [FromQuery] bool enableAdaptiveBitrateStreaming = true)
+ [FromQuery] bool enableAdaptiveBitrateStreaming = true,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
var streamingRequest = new HlsAudioRequestDto
{
@@ -671,7 +679,8 @@ public class DynamicHlsController : BaseJellyfinApiController
VideoStreamIndex = videoStreamIndex,
Context = context ?? EncodingContext.Streaming,
StreamOptions = streamOptions,
- EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
+ EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
@@ -730,6 +739,7 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
/// <param name="streamOptions">Optional. The streaming options.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Video stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("Videos/{itemId}/main.m3u8")]
@@ -785,7 +795,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
- [FromQuery] Dictionary<string, string> streamOptions)
+ [FromQuery] Dictionary<string, string> streamOptions,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
using var cancellationTokenSource = new CancellationTokenSource();
var streamingRequest = new VideoRequestDto
@@ -838,7 +849,8 @@ public class DynamicHlsController : BaseJellyfinApiController
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context ?? EncodingContext.Streaming,
- StreamOptions = streamOptions
+ StreamOptions = streamOptions,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
@@ -897,6 +909,7 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
/// <param name="streamOptions">Optional. The streaming options.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Audio stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("Audio/{itemId}/main.m3u8")]
@@ -951,7 +964,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
- [FromQuery] Dictionary<string, string> streamOptions)
+ [FromQuery] Dictionary<string, string> streamOptions,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
using var cancellationTokenSource = new CancellationTokenSource();
var streamingRequest = new StreamingRequestDto
@@ -1002,7 +1016,8 @@ public class DynamicHlsController : BaseJellyfinApiController
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context ?? EncodingContext.Streaming,
- StreamOptions = streamOptions
+ StreamOptions = streamOptions,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
return await GetVariantPlaylistInternal(streamingRequest, cancellationTokenSource)
@@ -1067,6 +1082,7 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
/// <param name="streamOptions">Optional. The streaming options.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Video stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
@@ -1128,7 +1144,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
- [FromQuery] Dictionary<string, string> streamOptions)
+ [FromQuery] Dictionary<string, string> streamOptions,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
var streamingRequest = new VideoRequestDto
{
@@ -1183,7 +1200,8 @@ public class DynamicHlsController : BaseJellyfinApiController
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context ?? EncodingContext.Streaming,
- StreamOptions = streamOptions
+ StreamOptions = streamOptions,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
return await GetDynamicSegment(streamingRequest, segmentId)
@@ -1247,6 +1265,7 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
/// <param name="streamOptions">Optional. The streaming options.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Video stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("Audio/{itemId}/hls1/{playlistId}/{segmentId}.{container}")]
@@ -1307,7 +1326,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
- [FromQuery] Dictionary<string, string> streamOptions)
+ [FromQuery] Dictionary<string, string> streamOptions,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
var streamingRequest = new StreamingRequestDto
{
@@ -1360,7 +1380,8 @@ public class DynamicHlsController : BaseJellyfinApiController
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context ?? EncodingContext.Streaming,
- StreamOptions = streamOptions
+ StreamOptions = streamOptions,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
return await GetDynamicSegment(streamingRequest, segmentId)
@@ -1481,7 +1502,7 @@ public class DynamicHlsController : BaseJellyfinApiController
if (currentTranscodingIndex.HasValue)
{
- DeleteLastFile(playlistPath, segmentExtension, 0);
+ await DeleteLastFile(playlistPath, segmentExtension, 0).ConfigureAwait(false);
}
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
@@ -1671,8 +1692,8 @@ public class DynamicHlsController : BaseJellyfinApiController
if (audioBitrate.HasValue && !EncodingHelper.LosslessAudioCodecs.Contains(state.ActualOutputAudioCodec, StringComparison.OrdinalIgnoreCase))
{
- var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, audioBitrate.Value / (audioChannels ?? 2));
- if (_encodingOptions.EnableAudioVbr && vbrParam is not null)
+ var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, audioBitrate.Value, audioChannels ?? 2);
+ if (_encodingOptions.EnableAudioVbr && state.EnableAudioVbrEncoding && vbrParam is not null)
{
audioTranscodeParams += vbrParam;
}
@@ -1712,12 +1733,11 @@ public class DynamicHlsController : BaseJellyfinApiController
var channels = state.OutputAudioChannels;
+ var useDownMixAlgorithm = state.AudioStream.Channels is 6 && _encodingOptions.DownMixStereoAlgorithm != DownMixStereoAlgorithms.None;
+
if (channels.HasValue
&& (channels.Value != 2
- || (state.AudioStream is not null
- && state.AudioStream.Channels.HasValue
- && state.AudioStream.Channels.Value > 5
- && _encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None)))
+ || (state.AudioStream?.Channels != null && !useDownMixAlgorithm)))
{
args += " -ac " + channels.Value;
}
@@ -1725,8 +1745,8 @@ public class DynamicHlsController : BaseJellyfinApiController
var bitrate = state.OutputAudioBitrate;
if (bitrate.HasValue && !EncodingHelper.LosslessAudioCodecs.Contains(actualOutputAudioCodec, StringComparison.OrdinalIgnoreCase))
{
- var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, bitrate.Value / (channels ?? 2));
- if (_encodingOptions.EnableAudioVbr && vbrParam is not null)
+ var vbrParam = _encodingHelper.GetAudioVbrModeParam(audioCodec, bitrate.Value, channels ?? 2);
+ if (_encodingOptions.EnableAudioVbr && state.EnableAudioVbrEncoding && vbrParam is not null)
{
args += vbrParam;
}
@@ -1740,6 +1760,12 @@ public class DynamicHlsController : BaseJellyfinApiController
{
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
}
+ else if (state.AudioStream?.CodecTag is not null && state.AudioStream.CodecTag.Equals("ac-4", StringComparison.Ordinal))
+ {
+ // ac-4 audio tends to hava a super weird sample rate that will fail most encoders
+ // force resample it to 48KHz
+ args += " -ar 48000";
+ }
args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions);
@@ -2010,17 +2036,19 @@ public class DynamicHlsController : BaseJellyfinApiController
}
}
- private void DeleteLastFile(string playlistPath, string segmentExtension, int retryCount)
+ private Task DeleteLastFile(string playlistPath, string segmentExtension, int retryCount)
{
var file = GetLastTranscodingFile(playlistPath, segmentExtension, _fileSystem);
- if (file is not null)
+ if (file is null)
{
- DeleteFile(file.FullName, retryCount);
+ return Task.CompletedTask;
}
+
+ return DeleteFile(file.FullName, retryCount);
}
- private void DeleteFile(string path, int retryCount)
+ private async Task DeleteFile(string path, int retryCount)
{
if (retryCount >= 5)
{
@@ -2037,9 +2065,8 @@ public class DynamicHlsController : BaseJellyfinApiController
{
_logger.LogError(ex, "Error deleting partial stream file(s) {Path}", path);
- var task = Task.Delay(100);
- task.Wait();
- DeleteFile(path, retryCount + 1);
+ await Task.Delay(100).ConfigureAwait(false);
+ await DeleteFile(path, retryCount + 1).ConfigureAwait(false);
}
catch (Exception ex)
{
diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs
index d6e043e6a..4abca3271 100644
--- a/Jellyfin.Api/Controllers/FilterController.cs
+++ b/Jellyfin.Api/Controllers/FilterController.cs
@@ -162,7 +162,7 @@ public class FilterController : BaseJellyfinApiController
}
else if (parentId.HasValue)
{
- parentItem = _libraryManager.GetItemById(parentId.Value);
+ parentItem = _libraryManager.GetItemById<BaseItem>(parentId.Value);
}
var filters = new QueryFilters();
diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs
index 6b38fa7d3..8e8accab3 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -90,6 +90,7 @@ public class ImageController : BaseJellyfinApiController
/// <param name="userId">User Id.</param>
/// <response code="204">Image updated.</response>
/// <response code="403">User does not have permission to delete the image.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("UserImage")]
[Authorize]
@@ -97,6 +98,7 @@ public class ImageController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> PostUserImage(
[FromQuery] Guid? userId)
{
@@ -289,7 +291,7 @@ public class ImageController : BaseJellyfinApiController
[FromRoute, Required] ImageType imageType,
[FromQuery] int? imageIndex)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -317,7 +319,7 @@ public class ImageController : BaseJellyfinApiController
[FromRoute, Required] ImageType imageType,
[FromRoute] int imageIndex)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -346,7 +348,7 @@ public class ImageController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -390,7 +392,7 @@ public class ImageController : BaseJellyfinApiController
[FromRoute, Required] ImageType imageType,
[FromRoute] int imageIndex)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -433,7 +435,7 @@ public class ImageController : BaseJellyfinApiController
[FromRoute, Required] int imageIndex,
[FromQuery, Required] int newIndex)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -456,7 +458,7 @@ public class ImageController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IEnumerable<ImageInfo>>> GetItemImageInfos([FromRoute, Required] Guid itemId)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -559,7 +561,7 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] string? foregroundLayer,
[FromQuery] int? imageIndex)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -637,7 +639,7 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -715,7 +717,7 @@ public class ImageController : BaseJellyfinApiController
[FromQuery] string? foregroundLayer,
[FromRoute, Required] int imageIndex)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs
index 3cf485299..dcbacf1d7 100644
--- a/Jellyfin.Api/Controllers/InstantMixController.cs
+++ b/Jellyfin.Api/Controllers/InstantMixController.cs
@@ -62,9 +62,11 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("Songs/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromSong(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
@@ -75,11 +77,16 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var item = _libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -99,9 +106,11 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("Albums/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromAlbum(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
@@ -112,15 +121,20 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var album = _libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
- var items = _musicManager.GetInstantMixFromItem(album, user, dtoOptions);
+ var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
return GetResult(items, user, limit, dtoOptions);
}
@@ -136,9 +150,11 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("Playlists/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromPlaylist(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
@@ -149,15 +165,20 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var playlist = (Playlist)_libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<Playlist>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
- var items = _musicManager.GetInstantMixFromItem(playlist, user, dtoOptions);
+ var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
return GetResult(items, user, limit, dtoOptions);
}
@@ -209,9 +230,11 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("Artists/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
@@ -222,11 +245,16 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var item = _libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -246,9 +274,11 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("Items/{itemId}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromItem(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
@@ -259,11 +289,16 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var item = _libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -283,9 +318,11 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("Artists/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
[Obsolete("Use GetInstantMixFromArtists")]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists2(
[FromQuery, Required] Guid id,
@@ -320,9 +357,11 @@ public class InstantMixController : BaseJellyfinApiController
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("MusicGenres/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById(
[FromQuery, Required] Guid id,
[FromQuery] Guid? userId,
@@ -333,11 +372,16 @@ public class InstantMixController : BaseJellyfinApiController
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
- var item = _libraryManager.GetItemById(id);
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(id, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs
index e3aee1bf7..d009f80a9 100644
--- a/Jellyfin.Api/Controllers/ItemLookupController.cs
+++ b/Jellyfin.Api/Controllers/ItemLookupController.cs
@@ -4,6 +4,8 @@ using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
@@ -64,7 +66,7 @@ public class ItemLookupController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<IEnumerable<ExternalIdInfo>> GetExternalIdInfos([FromRoute, Required] Guid itemId)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -234,6 +236,7 @@ public class ItemLookupController : BaseJellyfinApiController
/// <param name="searchResult">The remote search result.</param>
/// <param name="replaceAllImages">Optional. Whether or not to replace all images. Default: True.</param>
/// <response code="204">Item metadata refreshed.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>
/// A <see cref="Task" /> that represents the asynchronous operation to get the remote search results.
/// The task result contains an <see cref="NoContentResult"/>.
@@ -241,12 +244,18 @@ public class ItemLookupController : BaseJellyfinApiController
[HttpPost("Items/RemoteSearch/Apply/{itemId}")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> ApplySearchCriteria(
[FromRoute, Required] Guid itemId,
[FromBody, Required] RemoteSearchResult searchResult,
[FromQuery] bool replaceAllImages = true)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
+ if (item is null)
+ {
+ return NotFound();
+ }
+
_logger.LogInformation(
"Setting provider id's to item {ItemId}-{ItemName}: {@ProviderIds}",
item.Id,
diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs
index 0a8522e1c..d7a8c37c4 100644
--- a/Jellyfin.Api/Controllers/ItemRefreshController.cs
+++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs
@@ -2,7 +2,10 @@ using System;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Api;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
@@ -61,7 +64,7 @@ public class ItemRefreshController : BaseJellyfinApiController
[FromQuery] bool replaceAllMetadata = false,
[FromQuery] bool replaceAllImages = false)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -77,7 +80,8 @@ public class ItemRefreshController : BaseJellyfinApiController
|| imageRefreshMode == MetadataRefreshMode.FullRefresh
|| replaceAllImages
|| replaceAllMetadata,
- IsAutomated = false
+ IsAutomated = false,
+ RemoveOldMetadata = replaceAllMetadata
};
_providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 9800248c6..4001a6add 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -5,6 +5,8 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Configuration;
@@ -72,7 +74,7 @@ public class ItemUpdateController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UpdateItem([FromRoute, Required] Guid itemId, [FromBody, Required] BaseItemDto request)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -145,7 +147,11 @@ public class ItemUpdateController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<MetadataEditorInfo> GetMetadataEditorInfo([FromRoute, Required] Guid itemId)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
+ if (item is null)
+ {
+ return NotFound();
+ }
var info = new MetadataEditorInfo
{
@@ -197,7 +203,7 @@ public class ItemUpdateController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateItemContentType([FromRoute, Required] Guid itemId, [FromQuery] string? contentType)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -258,7 +264,7 @@ public class ItemUpdateController : BaseJellyfinApiController
if (request.Studios is not null)
{
- item.Studios = request.Studios.Select(x => x.Name).ToArray();
+ item.Studios = Array.ConvertAll(request.Studios, x => x.Name);
}
if (request.DateCreated.HasValue)
@@ -282,19 +288,37 @@ public class ItemUpdateController : BaseJellyfinApiController
if (item is Series rseries)
{
- foreach (Season season in rseries.Children)
+ foreach (var season in rseries.Children.OfType<Season>())
{
- season.OfficialRating = request.OfficialRating;
+ if (!season.LockedFields.Contains(MetadataField.OfficialRating))
+ {
+ season.OfficialRating = request.OfficialRating;
+ }
+
season.CustomRating = request.CustomRating;
- season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+
+ if (!season.LockedFields.Contains(MetadataField.Tags))
+ {
+ season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ }
+
season.OnMetadataChanged();
await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
- foreach (Episode ep in season.Children)
+ foreach (var ep in season.Children.OfType<Episode>())
{
- ep.OfficialRating = request.OfficialRating;
+ if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
+ {
+ ep.OfficialRating = request.OfficialRating;
+ }
+
ep.CustomRating = request.CustomRating;
- ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+
+ if (!ep.LockedFields.Contains(MetadataField.Tags))
+ {
+ ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ }
+
ep.OnMetadataChanged();
await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
@@ -302,11 +326,20 @@ public class ItemUpdateController : BaseJellyfinApiController
}
else if (item is Season season)
{
- foreach (Episode ep in season.Children)
+ foreach (var ep in season.Children.OfType<Episode>())
{
- ep.OfficialRating = request.OfficialRating;
+ if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
+ {
+ ep.OfficialRating = request.OfficialRating;
+ }
+
ep.CustomRating = request.CustomRating;
- ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+
+ if (!ep.LockedFields.Contains(MetadataField.Tags))
+ {
+ ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ }
+
ep.OnMetadataChanged();
await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
@@ -315,9 +348,18 @@ public class ItemUpdateController : BaseJellyfinApiController
{
foreach (BaseItem track in album.Children)
{
- track.OfficialRating = request.OfficialRating;
+ if (!track.LockedFields.Contains(MetadataField.OfficialRating))
+ {
+ track.OfficialRating = request.OfficialRating;
+ }
+
track.CustomRating = request.CustomRating;
- track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+
+ if (!track.LockedFields.Contains(MetadataField.Tags))
+ {
+ track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
+ }
+
track.OnMetadataChanged();
await track.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
@@ -373,10 +415,7 @@ public class ItemUpdateController : BaseJellyfinApiController
{
if (item is IHasAlbumArtist hasAlbumArtists)
{
- hasAlbumArtists.AlbumArtists = request
- .AlbumArtists
- .Select(i => i.Name)
- .ToArray();
+ hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name);
}
}
@@ -384,10 +423,7 @@ public class ItemUpdateController : BaseJellyfinApiController
{
if (item is IHasArtist hasArtists)
{
- hasArtists.Artists = request
- .ArtistItems
- .Select(i => i.Name)
- .ToArray();
+ hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name);
}
}
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 26ae1a820..d33634412 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -76,6 +76,7 @@ public class ItemsController : BaseJellyfinApiController
/// <param name="hasSpecialFeature">Optional filter by items with special features.</param>
/// <param name="hasTrailer">Optional filter by items with trailers.</param>
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
+ /// <param name="indexNumber">Optional filter by index number.</param>
/// <param name="parentIndexNumber">Optional filter by parent index number.</param>
/// <param name="hasParentalRating">Optional filter by items that have or do not have a parental rating.</param>
/// <param name="isHd">Optional filter by items that are HD or not.</param>
@@ -165,6 +166,7 @@ public class ItemsController : BaseJellyfinApiController
[FromQuery] bool? hasSpecialFeature,
[FromQuery] bool? hasTrailer,
[FromQuery] Guid? adjacentTo,
+ [FromQuery] int? indexNumber,
[FromQuery] int? parentIndexNumber,
[FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd,
@@ -246,9 +248,9 @@ public class ItemsController : BaseJellyfinApiController
var isApiKey = User.GetIsApiKey();
// if api key is used (auth.IsApiKey == true), then `user` will be null throughout this method
userId = RequestHelpers.GetUserId(User, userId);
- var user = !isApiKey && !userId.IsNullOrEmpty()
- ? _userManager.GetUserById(userId.Value) ?? throw new ResourceNotFoundException()
- : null;
+ var user = userId.IsNullOrEmpty()
+ ? null
+ : _userManager.GetUserById(userId.Value) ?? throw new ResourceNotFoundException();
// beyond this point, we're either using an api key or we have a valid user
if (!isApiKey && user is null)
@@ -256,6 +258,13 @@ public class ItemsController : BaseJellyfinApiController
return BadRequest("userId is required");
}
+ if (user is not null
+ && user.GetPreference(PreferenceKind.AllowedTags).Length != 0
+ && !fields.Contains(ItemFields.Tags))
+ {
+ fields = [..fields, ItemFields.Tags];
+ }
+
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -359,6 +368,7 @@ public class ItemsController : BaseJellyfinApiController
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
ParentId = parentId ?? Guid.Empty,
+ IndexNumber = indexNumber,
ParentIndexNumber = parentIndexNumber,
EnableTotalRecordCount = enableTotalRecordCount,
ExcludeItemIds = excludeItemIds,
@@ -710,6 +720,7 @@ public class ItemsController : BaseJellyfinApiController
hasSpecialFeature,
hasTrailer,
adjacentTo,
+ null,
parentIndexNumber,
hasParentalRating,
isHd,
@@ -967,9 +978,13 @@ public class ItemsController : BaseJellyfinApiController
}
var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException();
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
- return (item == null) ? NotFound() : _userDataRepository.GetUserDataDto(item, user);
+ return _userDataRepository.GetUserDataDto(item, user);
}
/// <summary>
@@ -1014,8 +1029,8 @@ public class ItemsController : BaseJellyfinApiController
}
var user = _userManager.GetUserById(requestUserId) ?? throw new ResourceNotFoundException();
- var item = _libraryManager.GetItemById(itemId);
- if (item == null)
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
{
return NotFound();
}
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index 984dc7789..62cb59335 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -102,7 +102,7 @@ public class LibraryController : BaseJellyfinApiController
[ProducesFile("video/*", "audio/*")]
public ActionResult GetFile([FromRoute, Required] Guid itemId)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -131,6 +131,8 @@ public class LibraryController : BaseJellyfinApiController
/// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="sortOrder">Optional. Sort Order - Ascending, Descending.</param>
/// <response code="200">Theme songs returned.</response>
/// <response code="404">Item not found.</response>
/// <returns>The item theme songs.</returns>
@@ -141,7 +143,9 @@ public class LibraryController : BaseJellyfinApiController
public ActionResult<ThemeMediaResult> GetThemeSongs(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
- [FromQuery] bool inheritFromParent = false)
+ [FromQuery] bool inheritFromParent = false,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null)
{
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
@@ -152,20 +156,23 @@ public class LibraryController : BaseJellyfinApiController
? (userId.IsNullOrEmpty()
? _libraryManager.RootFolder
: _libraryManager.GetUserRootFolder())
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
- return NotFound("Item not found.");
+ return NotFound();
}
- IEnumerable<BaseItem> themeItems;
+ sortOrder ??= [];
+ sortBy ??= [];
+ var orderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder);
+
+ IReadOnlyList<BaseItem> themeItems;
while (true)
{
- themeItems = item.GetThemeSongs();
+ themeItems = item.GetThemeSongs(user, orderBy);
- if (themeItems.Any() || !inheritFromParent)
+ if (themeItems.Count > 0 || !inheritFromParent)
{
break;
}
@@ -198,6 +205,8 @@ public class LibraryController : BaseJellyfinApiController
/// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="sortOrder">Optional. Sort Order - Ascending, Descending.</param>
/// <response code="200">Theme videos returned.</response>
/// <response code="404">Item not found.</response>
/// <returns>The item theme videos.</returns>
@@ -208,29 +217,33 @@ public class LibraryController : BaseJellyfinApiController
public ActionResult<ThemeMediaResult> GetThemeVideos(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
- [FromQuery] bool inheritFromParent = false)
+ [FromQuery] bool inheritFromParent = false,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null)
{
userId = RequestHelpers.GetUserId(User, userId);
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
-
var item = itemId.IsEmpty()
? (userId.IsNullOrEmpty()
? _libraryManager.RootFolder
: _libraryManager.GetUserRootFolder())
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
- return NotFound("Item not found.");
+ return NotFound();
}
+ sortOrder ??= [];
+ sortBy ??= [];
+ var orderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder);
+
IEnumerable<BaseItem> themeItems;
while (true)
{
- themeItems = item.GetThemeVideos();
+ themeItems = item.GetThemeVideos(user, orderBy);
if (themeItems.Any() || !inheritFromParent)
{
@@ -265,6 +278,8 @@ public class LibraryController : BaseJellyfinApiController
/// <param name="itemId">The item id.</param>
/// <param name="userId">Optional. Filter by user id, and attach user data.</param>
/// <param name="inheritFromParent">Optional. Determines whether or not parent items should be searched for theme media.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimited. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="sortOrder">Optional. Sort Order - Ascending, Descending.</param>
/// <response code="200">Theme songs and videos returned.</response>
/// <response code="404">Item not found.</response>
/// <returns>The item theme videos.</returns>
@@ -274,19 +289,26 @@ public class LibraryController : BaseJellyfinApiController
public ActionResult<AllThemeMediaResult> GetThemeMedia(
[FromRoute, Required] Guid itemId,
[FromQuery] Guid? userId,
- [FromQuery] bool inheritFromParent = false)
+ [FromQuery] bool inheritFromParent = false,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemSortBy[]? sortBy = null,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SortOrder[]? sortOrder = null)
{
var themeSongs = GetThemeSongs(
itemId,
userId,
- inheritFromParent);
+ inheritFromParent,
+ sortBy,
+ sortOrder);
var themeVideos = GetThemeVideos(
itemId,
userId,
- inheritFromParent);
+ inheritFromParent,
+ sortBy,
+ sortOrder);
- if (themeSongs.Result is NotFoundObjectResult || themeVideos.Result is NotFoundObjectResult)
+ if (themeSongs.Result is StatusCodeResult { StatusCode: StatusCodes.Status404NotFound }
+ || themeVideos.Result is StatusCodeResult { StatusCode: StatusCodes.Status404NotFound })
{
return NotFound();
}
@@ -327,6 +349,7 @@ public class LibraryController : BaseJellyfinApiController
/// <param name="itemId">The item id.</param>
/// <response code="204">Item deleted.</response>
/// <response code="401">Unauthorized access.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("Items/{itemId}")]
[Authorize]
@@ -335,17 +358,18 @@ public class LibraryController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DeleteItem(Guid itemId)
{
- var isApiKey = User.GetIsApiKey();
var userId = User.GetUserId();
- var user = !isApiKey && !userId.IsEmpty()
- ? _userManager.GetUserById(userId) ?? throw new ResourceNotFoundException()
- : null;
- if (!isApiKey && user is null)
+ var isApiKey = User.GetIsApiKey();
+ var user = userId.IsEmpty() && isApiKey
+ ? null
+ : _userManager.GetUserById(userId);
+
+ if (user is null && !isApiKey)
{
- return Unauthorized("Unauthorized access");
+ return NotFound();
}
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
@@ -391,7 +415,7 @@ public class LibraryController : BaseJellyfinApiController
foreach (var i in ids)
{
- var item = _libraryManager.GetItemById(i);
+ var item = _libraryManager.GetItemById<BaseItem>(i, user);
if (item is null)
{
return NotFound();
@@ -459,20 +483,18 @@ public class LibraryController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<IEnumerable<BaseItemDto>> GetAncestors([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
{
- var item = _libraryManager.GetItemById(itemId);
userId = RequestHelpers.GetUserId(User, userId);
-
+ var user = userId.IsNullOrEmpty()
+ ? null
+ : _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
- return NotFound("Item not found");
+ return NotFound();
}
var baseItemDtos = new List<BaseItemDto>();
- var user = userId.IsNullOrEmpty()
- ? null
- : _userManager.GetUserById(userId.Value);
-
var dtoOptions = new DtoOptions().AddClientFields(User);
BaseItem? parent = item.GetParent();
@@ -520,7 +542,11 @@ public class LibraryController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden)
{
- var items = _libraryManager.GetUserRootFolder().Children.Concat(_libraryManager.RootFolder.VirtualChildren).OrderBy(i => i.SortName).ToList();
+ var items = _libraryManager.GetUserRootFolder().Children
+ .Concat(_libraryManager.RootFolder.VirtualChildren)
+ .Where(i => _libraryManager.GetLibraryOptions(i).Enabled)
+ .OrderBy(i => i.SortName)
+ .ToList();
if (isHidden.HasValue)
{
@@ -640,14 +666,16 @@ public class LibraryController : BaseJellyfinApiController
[ProducesFile("video/*", "audio/*")]
public async Task<ActionResult> GetDownload([FromRoute, Required] Guid itemId)
{
- var item = _libraryManager.GetItemById(itemId);
+ var userId = User.GetUserId();
+ var user = userId.IsEmpty()
+ ? null
+ : _userManager.GetUserById(userId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
- var user = _userManager.GetUserById(User.GetUserId());
-
if (user is not null)
{
if (!item.CanDownload(user))
@@ -700,12 +728,14 @@ public class LibraryController : BaseJellyfinApiController
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
{
userId = RequestHelpers.GetUserId(User, userId);
+ var user = userId.IsNullOrEmpty()
+ ? null
+ : _userManager.GetUserById(userId.Value);
var item = itemId.IsEmpty()
- ? (userId.IsNullOrEmpty()
+ ? (user is null
? _libraryManager.RootFolder
: _libraryManager.GetUserRootFolder())
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
@@ -716,9 +746,6 @@ public class LibraryController : BaseJellyfinApiController
return new QueryResult<BaseItemDto>();
}
- var user = userId.IsNullOrEmpty()
- ? null
- : _userManager.GetUserById(userId.Value);
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User);
diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs
index 23c430f85..93c2393f3 100644
--- a/Jellyfin.Api/Controllers/LibraryStructureController.cs
+++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs
@@ -6,6 +6,8 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.LibraryStructureDto;
using MediaBrowser.Common.Api;
@@ -73,7 +75,7 @@ public class LibraryStructureController : BaseJellyfinApiController
[HttpPost]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> AddVirtualFolder(
- [FromQuery] string? name,
+ [FromQuery] string name,
[FromQuery] CollectionTypeOptions? collectionType,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] paths,
[FromBody] AddVirtualFolderDto? libraryOptionsDto,
@@ -83,7 +85,7 @@ public class LibraryStructureController : BaseJellyfinApiController
if (paths is not null && paths.Length > 0)
{
- libraryOptions.PathInfos = paths.Select(i => new MediaPathInfo(i)).ToArray();
+ libraryOptions.PathInfos = Array.ConvertAll(paths, i => new MediaPathInfo(i));
}
await _libraryManager.AddVirtualFolder(name, collectionType, libraryOptions, refreshLibrary).ConfigureAwait(false);
@@ -101,7 +103,7 @@ public class LibraryStructureController : BaseJellyfinApiController
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> RemoveVirtualFolder(
- [FromQuery] string? name,
+ [FromQuery] string name,
[FromQuery] bool refreshLibrary = false)
{
await _libraryManager.RemoveVirtualFolder(name, refreshLibrary).ConfigureAwait(false);
@@ -178,7 +180,21 @@ public class LibraryStructureController : BaseJellyfinApiController
// No need to start if scanning the library because it will handle it
if (refreshLibrary)
{
- await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
+ var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase));
+ if (newLib is CollectionFolder folder)
+ {
+ foreach (var child in folder.GetPhysicalFolders())
+ {
+ await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
+ await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ // We don't know if this one can be validated individually, trigger a new validation
+ await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
+ }
}
else
{
@@ -265,18 +281,16 @@ public class LibraryStructureController : BaseJellyfinApiController
/// <param name="refreshLibrary">Whether to refresh the library.</param>
/// <returns>A <see cref="NoContentResult"/>.</returns>
/// <response code="204">Media path removed.</response>
- /// <exception cref="ArgumentNullException">The name of the library may not be empty.</exception>
+ /// <exception cref="ArgumentException">The name of the library and path may not be empty.</exception>
[HttpDelete("Paths")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult RemoveMediaPath(
- [FromQuery] string? name,
- [FromQuery] string? path,
+ [FromQuery] string name,
+ [FromQuery] string path,
[FromQuery] bool refreshLibrary = false)
{
- if (string.IsNullOrWhiteSpace(name))
- {
- throw new ArgumentNullException(nameof(name));
- }
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(path);
_libraryMonitor.Stop();
@@ -311,15 +325,21 @@ public class LibraryStructureController : BaseJellyfinApiController
/// </summary>
/// <param name="request">The library name and options.</param>
/// <response code="204">Library updated.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("LibraryOptions")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UpdateLibraryOptions(
[FromBody] UpdateLibraryOptionsDto request)
{
- var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(request.Id);
+ var item = _libraryManager.GetItemById<CollectionFolder>(request.Id);
+ if (item is null)
+ {
+ return NotFound();
+ }
- collectionFolder.UpdateLibraryOptions(request.LibraryOptions);
+ item.UpdateLibraryOptions(request.LibraryOptions);
return NoContent();
}
}
diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs
index 7768b3c45..2b26c01f8 100644
--- a/Jellyfin.Api/Controllers/LiveTvController.cs
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -220,9 +220,11 @@ public class LiveTvController : BaseJellyfinApiController
/// <param name="channelId">Channel id.</param>
/// <param name="userId">Optional. Attach user data.</param>
/// <response code="200">Live tv channel returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>An <see cref="OkResult"/> containing the live tv channel.</returns>
[HttpGet("Channels/{channelId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
[Authorize(Policy = Policies.LiveTvAccess)]
public ActionResult<BaseItemDto> GetChannel([FromRoute, Required] Guid channelId, [FromQuery] Guid? userId)
{
@@ -232,7 +234,12 @@ public class LiveTvController : BaseJellyfinApiController
: _userManager.GetUserById(userId.Value);
var item = channelId.IsEmpty()
? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(channelId);
+ : _libraryManager.GetItemById<BaseItem>(channelId, user);
+
+ if (item is null)
+ {
+ return NotFound();
+ }
var dtoOptions = new DtoOptions()
.AddClientFields(User);
@@ -416,9 +423,11 @@ public class LiveTvController : BaseJellyfinApiController
/// <param name="recordingId">Recording id.</param>
/// <param name="userId">Optional. Attach user data.</param>
/// <response code="200">Recording returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>An <see cref="OkResult"/> containing the live tv recording.</returns>
[HttpGet("Recordings/{recordingId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
[Authorize(Policy = Policies.LiveTvAccess)]
public ActionResult<BaseItemDto> GetRecording([FromRoute, Required] Guid recordingId, [FromQuery] Guid? userId)
{
@@ -426,7 +435,13 @@ public class LiveTvController : BaseJellyfinApiController
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
- var item = recordingId.IsEmpty() ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(recordingId);
+ var item = recordingId.IsEmpty()
+ ? _libraryManager.GetUserRootFolder()
+ : _libraryManager.GetItemById<BaseItem>(recordingId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
var dtoOptions = new DtoOptions()
.AddClientFields(User);
@@ -611,7 +626,8 @@ public class LiveTvController : BaseJellyfinApiController
{
query.IsSeries = true;
- if (_libraryManager.GetItemById(librarySeriesId.Value) is Series series)
+ var series = _libraryManager.GetItemById<Series>(librarySeriesId.Value);
+ if (series is not null)
{
query.Name = series.Name;
}
@@ -665,7 +681,8 @@ public class LiveTvController : BaseJellyfinApiController
{
query.IsSeries = true;
- if (_libraryManager.GetItemById(body.LibrarySeriesId) is Series series)
+ var series = _libraryManager.GetItemById<Series>(body.LibrarySeriesId);
+ if (series is not null)
{
query.Name = series.Name;
}
@@ -779,7 +796,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DeleteRecording([FromRoute, Required] Guid recordingId)
{
- var item = _libraryManager.GetItemById(recordingId);
+ var item = _libraryManager.GetItemById<BaseItem>(recordingId, User.GetUserId());
if (item is null)
{
return NotFound();
diff --git a/Jellyfin.Api/Controllers/LyricsController.cs b/Jellyfin.Api/Controllers/LyricsController.cs
index f2b312b47..8eb4cadf8 100644
--- a/Jellyfin.Api/Controllers/LyricsController.cs
+++ b/Jellyfin.Api/Controllers/LyricsController.cs
@@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using Jellyfin.Extensions;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities.Audio;
@@ -66,37 +67,16 @@ public class LyricsController : BaseJellyfinApiController
[HttpGet("Audio/{itemId}/Lyrics")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LyricDto>> GetLyrics([FromRoute, Required] Guid itemId)
{
- var isApiKey = User.GetIsApiKey();
- var userId = User.GetUserId();
- if (!isApiKey && userId.IsEmpty())
- {
- return BadRequest();
- }
-
- var audio = _libraryManager.GetItemById<Audio>(itemId);
- if (audio is null)
+ var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
+ if (item is null)
{
return NotFound();
}
- if (!isApiKey)
- {
- var user = _userManager.GetUserById(userId);
- if (user is null)
- {
- return NotFound();
- }
-
- // Check the item is visible for the user
- if (!audio.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {audio.Name}.");
- }
- }
-
- var result = await _lyricManager.GetLyricsAsync(audio, CancellationToken.None).ConfigureAwait(false);
+ var result = await _lyricManager.GetLyricsAsync(item, CancellationToken.None).ConfigureAwait(false);
if (result is not null)
{
return Ok(result);
@@ -124,8 +104,8 @@ public class LyricsController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId,
[FromQuery, Required] string fileName)
{
- var audio = _libraryManager.GetItemById<Audio>(itemId);
- if (audio is null)
+ var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
+ if (item is null)
{
return NotFound();
}
@@ -147,7 +127,7 @@ public class LyricsController : BaseJellyfinApiController
{
await Request.Body.CopyToAsync(stream).ConfigureAwait(false);
var uploadedLyric = await _lyricManager.SaveLyricAsync(
- audio,
+ item,
format,
stream)
.ConfigureAwait(false);
@@ -157,7 +137,7 @@ public class LyricsController : BaseJellyfinApiController
return BadRequest();
}
- _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
return Ok(uploadedLyric);
}
}
@@ -176,13 +156,13 @@ public class LyricsController : BaseJellyfinApiController
public async Task<ActionResult> DeleteLyrics(
[FromRoute, Required] Guid itemId)
{
- var audio = _libraryManager.GetItemById<Audio>(itemId);
- if (audio is null)
+ var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
+ if (item is null)
{
return NotFound();
}
- await _lyricManager.DeleteLyricsAsync(audio).ConfigureAwait(false);
+ await _lyricManager.DeleteLyricsAsync(item).ConfigureAwait(false);
return NoContent();
}
@@ -199,13 +179,13 @@ public class LyricsController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<RemoteLyricInfoDto>>> SearchRemoteLyrics([FromRoute, Required] Guid itemId)
{
- var audio = _libraryManager.GetItemById<Audio>(itemId);
- if (audio is null)
+ var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
+ if (item is null)
{
return NotFound();
}
- var results = await _lyricManager.SearchLyricsAsync(audio, false, CancellationToken.None).ConfigureAwait(false);
+ var results = await _lyricManager.SearchLyricsAsync(item, false, CancellationToken.None).ConfigureAwait(false);
return Ok(results);
}
@@ -225,19 +205,19 @@ public class LyricsController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string lyricId)
{
- var audio = _libraryManager.GetItemById<Audio>(itemId);
- if (audio is null)
+ var item = _libraryManager.GetItemById<Audio>(itemId, User.GetUserId());
+ if (item is null)
{
return NotFound();
}
- var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(audio, lyricId, CancellationToken.None).ConfigureAwait(false);
+ var downloadedLyrics = await _lyricManager.DownloadLyricsAsync(item, lyricId, CancellationToken.None).ConfigureAwait(false);
if (downloadedLyrics is null)
{
return NotFound();
}
- _providerManager.QueueRefresh(audio.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
return Ok(downloadedLyrics);
}
diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs
index 742012b71..bc52be184 100644
--- a/Jellyfin.Api/Controllers/MediaInfoController.cs
+++ b/Jellyfin.Api/Controllers/MediaInfoController.cs
@@ -8,8 +8,10 @@ using Jellyfin.Api.Attributes;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.MediaInfoDtos;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.MediaInfo;
using Microsoft.AspNetCore.Authorization;
@@ -32,6 +34,7 @@ public class MediaInfoController : BaseJellyfinApiController
private readonly ILibraryManager _libraryManager;
private readonly ILogger<MediaInfoController> _logger;
private readonly MediaInfoHelper _mediaInfoHelper;
+ private readonly IUserManager _userManager;
/// <summary>
/// Initializes a new instance of the <see cref="MediaInfoController"/> class.
@@ -41,18 +44,21 @@ public class MediaInfoController : BaseJellyfinApiController
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{MediaInfoController}"/> interface.</param>
/// <param name="mediaInfoHelper">Instance of the <see cref="MediaInfoHelper"/>.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface..</param>
public MediaInfoController(
IMediaSourceManager mediaSourceManager,
IDeviceManager deviceManager,
ILibraryManager libraryManager,
ILogger<MediaInfoController> logger,
- MediaInfoHelper mediaInfoHelper)
+ MediaInfoHelper mediaInfoHelper,
+ IUserManager userManager)
{
_mediaSourceManager = mediaSourceManager;
_deviceManager = deviceManager;
_libraryManager = libraryManager;
_logger = logger;
_mediaInfoHelper = mediaInfoHelper;
+ _userManager = userManager;
}
/// <summary>
@@ -61,16 +67,24 @@ public class MediaInfoController : BaseJellyfinApiController
/// <param name="itemId">The item id.</param>
/// <param name="userId">The user id.</param>
/// <response code="200">Playback info returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback information.</returns>
[HttpGet("Items/{itemId}/PlaybackInfo")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlaybackInfoResponse>> GetPlaybackInfo([FromRoute, Required] Guid itemId, [FromQuery] Guid? userId)
{
userId = RequestHelpers.GetUserId(User, userId);
- return await _mediaInfoHelper.GetPlaybackInfo(
- itemId,
- userId)
- .ConfigureAwait(false);
+ var user = userId.IsNullOrEmpty()
+ ? null
+ : _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
+ return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false);
}
/// <summary>
@@ -97,9 +111,11 @@ public class MediaInfoController : BaseJellyfinApiController
/// <param name="allowAudioStreamCopy">Whether to allow to copy the audio stream. Default: true.</param>
/// <param name="playbackInfoDto">The playback info.</param>
/// <response code="200">Playback info returned.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns>
[HttpPost("Items/{itemId}/PlaybackInfo")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlaybackInfoResponse>> GetPostedPlaybackInfo(
[FromRoute, Required] Guid itemId,
[FromQuery, ParameterObsolete] Guid? userId,
@@ -148,9 +164,19 @@ public class MediaInfoController : BaseJellyfinApiController
allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true;
allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true;
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = userId.IsNullOrEmpty()
+ ? null
+ : _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
var info = await _mediaInfoHelper.GetPlaybackInfo(
- itemId,
- userId,
+ item,
+ user,
mediaSourceId,
liveStreamId)
.ConfigureAwait(false);
@@ -163,8 +189,6 @@ public class MediaInfoController : BaseJellyfinApiController
if (profile is not null)
{
// set device specific data
- var item = _libraryManager.GetItemById(itemId);
-
foreach (var mediaSource in info.MediaSources)
{
_mediaInfoHelper.SetDeviceSpecificData(
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
index c5e940108..274e94ee6 100644
--- a/Jellyfin.Api/Controllers/PackageController.cs
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -18,7 +18,7 @@ namespace Jellyfin.Api.Controllers;
/// Package Controller.
/// </summary>
[Route("")]
-[Authorize]
+[Authorize(Policy = Policies.RequiresElevation)]
public class PackageController : BaseJellyfinApiController
{
private readonly IInstallationManager _installationManager;
@@ -90,7 +90,6 @@ public class PackageController : BaseJellyfinApiController
[HttpPost("Packages/Installed/{name}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
- [Authorize(Policy = Policies.RequiresElevation)]
public async Task<ActionResult> InstallPackage(
[FromRoute, Required] string name,
[FromQuery] Guid? assemblyGuid,
@@ -128,7 +127,6 @@ public class PackageController : BaseJellyfinApiController
/// <response code="204">Installation cancelled.</response>
/// <returns>A <see cref="NoContentResult"/> on successfully cancelling a package installation.</returns>
[HttpDelete("Packages/Installing/{packageId}")]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult CancelPackageInstallation(
[FromRoute, Required] Guid packageId)
@@ -156,7 +154,6 @@ public class PackageController : BaseJellyfinApiController
/// <response code="204">Package repositories saved.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Repositories")]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SetRepositories([FromBody, Required] RepositoryInfo[] repositoryInfos)
{
diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs
index 0e7c3f155..63d6e1cc3 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -92,29 +92,271 @@ public class PlaylistsController : BaseJellyfinApiController
Name = name ?? createPlaylistRequest?.Name,
ItemIdList = ids,
UserId = userId.Value,
- MediaType = mediaType ?? createPlaylistRequest?.MediaType
+ MediaType = mediaType ?? createPlaylistRequest?.MediaType,
+ Users = createPlaylistRequest?.Users.ToArray() ?? [],
+ Public = createPlaylistRequest?.IsPublic
}).ConfigureAwait(false);
return result;
}
/// <summary>
+ /// Updates a playlist.
+ /// </summary>
+ /// <param name="playlistId">The playlist id.</param>
+ /// <param name="updatePlaylistRequest">The <see cref="UpdatePlaylistDto"/> id.</param>
+ /// <response code="204">Playlist updated.</response>
+ /// <response code="403">Access forbidden.</response>
+ /// <response code="404">Playlist not found.</response>
+ /// <returns>
+ /// A <see cref="Task" /> that represents the asynchronous operation to update a playlist.
+ /// The task result contains an <see cref="OkResult"/> indicating success.
+ /// </returns>
+ [HttpPost("{playlistId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> UpdatePlaylist(
+ [FromRoute, Required] Guid playlistId,
+ [FromBody, Required] UpdatePlaylistDto updatePlaylistRequest)
+ {
+ var callingUserId = User.GetUserId();
+
+ var playlist = _playlistManager.GetPlaylistForUser(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();
+ }
+
+ await _playlistManager.UpdatePlaylist(new PlaylistUpdateRequest
+ {
+ UserId = callingUserId,
+ Id = playlistId,
+ Name = updatePlaylistRequest.Name,
+ Ids = updatePlaylistRequest.Ids,
+ Users = updatePlaylistRequest.Users,
+ Public = updatePlaylistRequest.IsPublic
+ }).ConfigureAwait(false);
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Get a playlist's users.
+ /// </summary>
+ /// <param name="playlistId">The playlist id.</param>
+ /// <response code="200">Found shares.</response>
+ /// <response code="403">Access forbidden.</response>
+ /// <response code="404">Playlist not found.</response>
+ /// <returns>
+ /// A list of <see cref="PlaylistUserPermissions"/> objects.
+ /// </returns>
+ [HttpGet("{playlistId}/Users")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<IReadOnlyList<PlaylistUserPermissions>> GetPlaylistUsers(
+ [FromRoute, Required] Guid playlistId)
+ {
+ var userId = User.GetUserId();
+
+ var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId);
+ if (playlist is null)
+ {
+ return NotFound("Playlist not found");
+ }
+
+ var isPermitted = playlist.OwnerUserId.Equals(userId);
+
+ return isPermitted ? playlist.Shares.ToList() : Forbid();
+ }
+
+ /// <summary>
+ /// Get a playlist user.
+ /// </summary>
+ /// <param name="playlistId">The playlist id.</param>
+ /// <param name="userId">The user id.</param>
+ /// <response code="200">User permission found.</response>
+ /// <response code="403">Access forbidden.</response>
+ /// <response code="404">Playlist not found.</response>
+ /// <returns>
+ /// <see cref="PlaylistUserPermissions"/>.
+ /// </returns>
+ [HttpGet("{playlistId}/Users/{userId}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public ActionResult<PlaylistUserPermissions?> GetPlaylistUser(
+ [FromRoute, Required] Guid playlistId,
+ [FromRoute, Required] Guid userId)
+ {
+ var callingUserId = User.GetUserId();
+
+ var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
+ if (playlist is null)
+ {
+ return NotFound("Playlist not found");
+ }
+
+ if (playlist.OwnerUserId.Equals(callingUserId))
+ {
+ return new PlaylistUserPermissions(callingUserId, true);
+ }
+
+ var userPermission = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId));
+ var isPermitted = playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(callingUserId))
+ || userId.Equals(callingUserId);
+
+ if (!isPermitted)
+ {
+ return Forbid();
+ }
+
+ if (userPermission is not null)
+ {
+ return userPermission;
+ }
+
+ return NotFound("User permissions not found");
+ }
+
+ /// <summary>
+ /// Modify a user of a playlist's users.
+ /// </summary>
+ /// <param name="playlistId">The playlist id.</param>
+ /// <param name="userId">The user id.</param>
+ /// <param name="updatePlaylistUserRequest">The <see cref="UpdatePlaylistUserDto"/>.</param>
+ /// <response code="204">User's permissions modified.</response>
+ /// <response code="403">Access forbidden.</response>
+ /// <response code="404">Playlist not found.</response>
+ /// <returns>
+ /// A <see cref="Task" /> that represents the asynchronous operation to modify an user's playlist permissions.
+ /// The task result contains an <see cref="OkResult"/> indicating success.
+ /// </returns>
+ [HttpPost("{playlistId}/Users/{userId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> UpdatePlaylistUser(
+ [FromRoute, Required] Guid playlistId,
+ [FromRoute, Required] Guid userId,
+ [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow), Required] UpdatePlaylistUserDto updatePlaylistUserRequest)
+ {
+ var callingUserId = User.GetUserId();
+
+ var playlist = _playlistManager.GetPlaylistForUser(playlistId, callingUserId);
+ if (playlist is null)
+ {
+ return NotFound("Playlist not found");
+ }
+
+ var isPermitted = playlist.OwnerUserId.Equals(callingUserId);
+
+ if (!isPermitted)
+ {
+ return Forbid();
+ }
+
+ await _playlistManager.AddUserToShares(new PlaylistUserUpdateRequest
+ {
+ Id = playlistId,
+ UserId = userId,
+ CanEdit = updatePlaylistUserRequest.CanEdit
+ }).ConfigureAwait(false);
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Remove a user from a playlist's users.
+ /// </summary>
+ /// <param name="playlistId">The playlist id.</param>
+ /// <param name="userId">The user id.</param>
+ /// <response code="204">User permissions removed from playlist.</response>
+ /// <response code="401">Unauthorized access.</response>
+ /// <response code="404">No playlist or user permissions found.</response>
+ /// <returns>
+ /// A <see cref="Task" /> that represents the asynchronous operation to delete a user from a playlist's shares.
+ /// The task result contains an <see cref="OkResult"/> indicating success.
+ /// </returns>
+ [HttpDelete("{playlistId}/Users/{userId}")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> RemoveUserFromPlaylist(
+ [FromRoute, Required] Guid playlistId,
+ [FromRoute, Required] Guid userId)
+ {
+ var callingUserId = User.GetUserId();
+
+ var playlist = _playlistManager.GetPlaylistForUser(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();
+ }
+
+ var share = playlist.Shares.FirstOrDefault(s => s.UserId.Equals(userId));
+ if (share is null)
+ {
+ return NotFound("User permissions not found");
+ }
+
+ await _playlistManager.RemoveUserFromShares(playlistId, callingUserId, share).ConfigureAwait(false);
+
+ return NoContent();
+ }
+
+ /// <summary>
/// Adds items to a playlist.
/// </summary>
/// <param name="playlistId">The playlist id.</param>
/// <param name="ids">Item id, comma delimited.</param>
/// <param name="userId">The userId.</param>
/// <response code="204">Items added to playlist.</response>
+ /// <response code="403">Access forbidden.</response>
+ /// <response code="404">Playlist not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task<ActionResult> AddToPlaylist(
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> AddItemToPlaylist(
[FromRoute, Required] Guid playlistId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
[FromQuery] Guid? userId)
{
userId = RequestHelpers.GetUserId(User, userId);
- await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false);
+ var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value);
+ if (playlist is null)
+ {
+ return NotFound("Playlist not found");
+ }
+
+ var isPermitted = playlist.OwnerUserId.Equals(userId.Value)
+ || playlist.Shares.Any(s => s.CanEdit && s.UserId.Equals(userId.Value));
+
+ if (!isPermitted)
+ {
+ return Forbid();
+ }
+
+ await _playlistManager.AddItemToPlaylistAsync(playlistId, ids, userId.Value).ConfigureAwait(false);
return NoContent();
}
@@ -125,14 +367,34 @@ public class PlaylistsController : BaseJellyfinApiController
/// <param name="itemId">The item id.</param>
/// <param name="newIndex">The new index.</param>
/// <response code="204">Item moved to new index.</response>
+ /// <response code="403">Access forbidden.</response>
+ /// <response code="404">Playlist not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpPost("{playlistId}/Items/{itemId}/Move/{newIndex}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> MoveItem(
[FromRoute, Required] string playlistId,
[FromRoute, Required] string itemId,
[FromRoute, Required] int newIndex)
{
+ var callingUserId = User.GetUserId();
+
+ 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();
+ }
+
await _playlistManager.MoveItemAsync(playlistId, itemId, newIndex).ConfigureAwait(false);
return NoContent();
}
@@ -143,14 +405,34 @@ public class PlaylistsController : BaseJellyfinApiController
/// <param name="playlistId">The playlist id.</param>
/// <param name="entryIds">The item ids, comma delimited.</param>
/// <response code="204">Items removed.</response>
+ /// <response code="403">Access forbidden.</response>
+ /// <response code="404">Playlist not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpDelete("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task<ActionResult> RemoveFromPlaylist(
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> RemoveItemFromPlaylist(
[FromRoute, Required] string playlistId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds)
{
- await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
+ var callingUserId = User.GetUserId();
+
+ 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();
+ }
+
+ await _playlistManager.RemoveItemFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
return NoContent();
}
@@ -167,10 +449,12 @@ public class PlaylistsController : BaseJellyfinApiController
/// <param name="imageTypeLimit">Optional. The max number of images to return, per image type.</param>
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Original playlist returned.</response>
+ /// <response code="404">Access forbidden.</response>
/// <response code="404">Playlist not found.</response>
/// <returns>The original playlist items.</returns>
[HttpGet("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetPlaylistItems(
[FromRoute, Required] Guid playlistId,
@@ -184,17 +468,31 @@ public class PlaylistsController : BaseJellyfinApiController
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes)
{
userId = RequestHelpers.GetUserId(User, userId);
- var playlist = (Playlist)_libraryManager.GetItemById(playlistId);
+ var playlist = _playlistManager.GetPlaylistForUser(playlistId, userId.Value);
if (playlist is null)
{
- return NotFound();
+ return NotFound("Playlist not found");
+ }
+
+ var isPermitted = playlist.OpenAccess
+ || playlist.OwnerUserId.Equals(userId.Value)
+ || playlist.Shares.Any(s => s.UserId.Equals(userId.Value));
+
+ if (!isPermitted)
+ {
+ return Forbid();
}
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<Playlist>(playlistId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
- var items = playlist.GetManageableItems().ToArray();
+ var items = item.GetManageableItems().ToArray();
var count = items.Length;
if (startIndex.HasValue)
{
diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs
index 949d101dc..88aa0178f 100644
--- a/Jellyfin.Api/Controllers/PlaystateController.cs
+++ b/Jellyfin.Api/Controllers/PlaystateController.cs
@@ -6,6 +6,7 @@ using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Entities;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
@@ -76,21 +77,21 @@ public class PlaystateController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId,
[FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
}
- var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
-
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
+ var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext, userId).ConfigureAwait(false);
+
var dto = UpdatePlayedStatus(user, item, true, datePlayed);
foreach (var additionalUserInfo in session.AdditionalUsers)
{
@@ -141,21 +142,21 @@ public class PlaystateController : BaseJellyfinApiController
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
}
- var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
- var item = _libraryManager.GetItemById(itemId);
-
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
+ var session = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext, userId).ConfigureAwait(false);
+
var dto = UpdatePlayedStatus(user, item, false, null);
foreach (var additionalUserInfo in session.AdditionalUsers)
{
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
index f63e63927..6abd7a23e 100644
--- a/Jellyfin.Api/Controllers/PluginsController.cs
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -22,7 +22,7 @@ namespace Jellyfin.Api.Controllers;
/// <summary>
/// Plugins controller.
/// </summary>
-[Authorize]
+[Authorize(Policy = Policies.RequiresElevation)]
public class PluginsController : BaseJellyfinApiController
{
private readonly IInstallationManager _installationManager;
@@ -66,7 +66,6 @@ public class PluginsController : BaseJellyfinApiController
/// <response code="404">Plugin not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
[HttpPost("{pluginId}/{version}/Enable")]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult EnablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
@@ -90,7 +89,6 @@ public class PluginsController : BaseJellyfinApiController
/// <response code="404">Plugin not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
[HttpPost("{pluginId}/{version}/Disable")]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult DisablePlugin([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
@@ -114,7 +112,6 @@ public class PluginsController : BaseJellyfinApiController
/// <response code="404">Plugin not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
[HttpDelete("{pluginId}/{version}")]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult UninstallPluginByVersion([FromRoute, Required] Guid pluginId, [FromRoute, Required] Version version)
@@ -137,7 +134,6 @@ public class PluginsController : BaseJellyfinApiController
/// <response code="404">Plugin not found.</response>
/// <returns>An <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if the plugin could not be found.</returns>
[HttpDelete("{pluginId}")]
- [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Obsolete("Please use the UninstallPluginByVersion API.")]
diff --git a/Jellyfin.Api/Controllers/RemoteImageController.cs b/Jellyfin.Api/Controllers/RemoteImageController.cs
index 595cab2df..a476005cb 100644
--- a/Jellyfin.Api/Controllers/RemoteImageController.cs
+++ b/Jellyfin.Api/Controllers/RemoteImageController.cs
@@ -6,8 +6,11 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -68,7 +71,7 @@ public class RemoteImageController : BaseJellyfinApiController
[FromQuery] string? providerName,
[FromQuery] bool includeAllLanguages = false)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -127,7 +130,7 @@ public class RemoteImageController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<IEnumerable<ImageProviderInfo>> GetRemoteImageProviders([FromRoute, Required] Guid itemId)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -154,7 +157,7 @@ public class RemoteImageController : BaseJellyfinApiController
[FromQuery, Required] ImageType type,
[FromQuery] string? imageUrl)
{
- var item = _libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs
index 413b7b834..8bae6fb9b 100644
--- a/Jellyfin.Api/Controllers/SearchController.cs
+++ b/Jellyfin.Api/Controllers/SearchController.cs
@@ -211,7 +211,7 @@ public class SearchController : BaseJellyfinApiController
if (!item.ChannelId.IsEmpty())
{
- var channel = _libraryManager.GetItemById(item.ChannelId);
+ var channel = _libraryManager.GetItemById<BaseItem>(item.ChannelId);
result.ChannelName = channel?.Name;
}
diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs
index 52b58b8f1..60de66ab0 100644
--- a/Jellyfin.Api/Controllers/SessionController.cs
+++ b/Jellyfin.Api/Controllers/SessionController.cs
@@ -84,7 +84,8 @@ public class SessionController : BaseJellyfinApiController
if (!user.HasPermission(PermissionKind.EnableRemoteControlOfOtherUsers))
{
- result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(controllableByUserId.Value));
+ // User cannot control other user's sessions, validate user id.
+ result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(RequestHelpers.GetUserId(User, controllableByUserId)));
}
if (!user.HasPermission(PermissionKind.EnableSharedDeviceControl))
@@ -105,6 +106,11 @@ public class SessionController : BaseJellyfinApiController
return true;
});
}
+ else if (!User.IsInRole(UserRoles.Administrator))
+ {
+ // Request isn't from administrator, limit to "own" sessions.
+ result = result.Where(i => i.UserId.IsEmpty() || i.ContainsUser(User.GetUserId()));
+ }
if (activeWithinSeconds.HasValue && activeWithinSeconds.Value > 0)
{
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index cc2a630e1..9da1dce93 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -12,6 +12,7 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.SubtitleDtos;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Configuration;
@@ -95,8 +96,7 @@ public class SubtitleController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId,
[FromRoute, Required] int index)
{
- var item = _libraryManager.GetItemById(itemId);
-
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -113,18 +113,24 @@ public class SubtitleController : BaseJellyfinApiController
/// <param name="language">The language of the subtitles.</param>
/// <param name="isPerfectMatch">Optional. Only show subtitles which are a perfect match.</param>
/// <response code="200">Subtitles retrieved.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>An array of <see cref="RemoteSubtitleInfo"/>.</returns>
[HttpGet("Items/{itemId}/RemoteSearch/Subtitles/{language}")]
[Authorize(Policy = Policies.SubtitleManagement)]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IEnumerable<RemoteSubtitleInfo>>> SearchRemoteSubtitles(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string language,
[FromQuery] bool? isPerfectMatch)
{
- var video = (Video)_libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
+ if (item is null)
+ {
+ return NotFound();
+ }
- return await _subtitleManager.SearchSubtitles(video, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false);
+ return await _subtitleManager.SearchSubtitles(item, language, isPerfectMatch, false, CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
@@ -133,22 +139,28 @@ public class SubtitleController : BaseJellyfinApiController
/// <param name="itemId">The item id.</param>
/// <param name="subtitleId">The subtitle id.</param>
/// <response code="204">Subtitle downloaded.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Items/{itemId}/RemoteSearch/Subtitles/{subtitleId}")]
[Authorize(Policy = Policies.SubtitleManagement)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DownloadRemoteSubtitles(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string subtitleId)
{
- var video = (Video)_libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
+ if (item is null)
+ {
+ return NotFound();
+ }
try
{
- await _subtitleManager.DownloadSubtitles(video, subtitleId, CancellationToken.None)
+ await _subtitleManager.DownloadSubtitles(item, subtitleId, CancellationToken.None)
.ConfigureAwait(false);
- _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
}
catch (Exception ex)
{
@@ -165,7 +177,7 @@ public class SubtitleController : BaseJellyfinApiController
/// <response code="200">File returned.</response>
/// <returns>A <see cref="FileStreamResult"/> with the subtitle file.</returns>
[HttpGet("Providers/Subtitles/Subtitles/{subtitleId}")]
- [Authorize]
+ [Authorize(Policy = Policies.SubtitleManagement)]
[ProducesResponseType(StatusCodes.Status200OK)]
[Produces(MediaTypeNames.Application.Octet)]
[ProducesFile("text/*")]
@@ -223,7 +235,7 @@ public class SubtitleController : BaseJellyfinApiController
if (string.IsNullOrEmpty(format))
{
- var item = (Video)_libraryManager.GetItemById(itemId.Value);
+ var item = _libraryManager.GetItemById<Video>(itemId.Value);
var idString = itemId.Value.ToString("N", CultureInfo.InvariantCulture);
var mediaSource = _mediaSourceManager.GetStaticMediaSources(item, false)
@@ -321,10 +333,12 @@ public class SubtitleController : BaseJellyfinApiController
/// <param name="mediaSourceId">The media source id.</param>
/// <param name="segmentLength">The subtitle segment length.</param>
/// <response code="200">Subtitle playlist retrieved.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="FileContentResult"/> with the HLS subtitle playlist.</returns>
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesPlaylistFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> GetSubtitlePlaylist(
@@ -333,7 +347,11 @@ public class SubtitleController : BaseJellyfinApiController
[FromRoute, Required] string mediaSourceId,
[FromQuery, Required] int segmentLength)
{
- var item = (Video)_libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
+ if (item is null)
+ {
+ return NotFound();
+ }
var mediaSource = await _mediaSourceManager.GetMediaSource(item, mediaSourceId, null, false, CancellationToken.None).ConfigureAwait(false);
@@ -397,15 +415,21 @@ public class SubtitleController : BaseJellyfinApiController
/// <param name="itemId">The item the subtitle belongs to.</param>
/// <param name="body">The request body.</param>
/// <response code="204">Subtitle uploaded.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Videos/{itemId}/Subtitles")]
[Authorize(Policy = Policies.SubtitleManagement)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> UploadSubtitle(
[FromRoute, Required] Guid itemId,
[FromBody, Required] UploadSubtitleDto body)
{
- var video = (Video)_libraryManager.GetItemById(itemId);
+ var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
+ if (item is null)
+ {
+ return NotFound();
+ }
var bytes = Encoding.UTF8.GetBytes(body.Data);
var memoryStream = new MemoryStream(bytes, 0, bytes.Length, false, true);
@@ -416,7 +440,7 @@ public class SubtitleController : BaseJellyfinApiController
await using (stream.ConfigureAwait(false))
{
await _subtitleManager.UploadSubtitle(
- video,
+ item,
new SubtitleResponse
{
Format = body.Format,
@@ -425,7 +449,7 @@ public class SubtitleController : BaseJellyfinApiController
IsHearingImpaired = body.IsHearingImpaired,
Stream = stream
}).ConfigureAwait(false);
- _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
+ _providerManager.QueueRefresh(item.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High);
return NoContent();
}
@@ -452,7 +476,7 @@ public class SubtitleController : BaseJellyfinApiController
long? endPositionTicks,
bool copyTimestamps)
{
- var item = _libraryManager.GetItemById(id);
+ var item = _libraryManager.GetItemById<BaseItem>(id);
return _subtitleEncoder.GetSubtitles(
item,
diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs
index 4fbaafa2a..d7d0cc454 100644
--- a/Jellyfin.Api/Controllers/TrailersController.cs
+++ b/Jellyfin.Api/Controllers/TrailersController.cs
@@ -215,6 +215,7 @@ public class TrailersController : BaseJellyfinApiController
hasSpecialFeature,
hasTrailer,
adjacentTo,
+ null,
parentIndexNumber,
hasParentalRating,
isHd,
diff --git a/Jellyfin.Api/Controllers/TrickplayController.cs b/Jellyfin.Api/Controllers/TrickplayController.cs
index 2dc960229..0afe053da 100644
--- a/Jellyfin.Api/Controllers/TrickplayController.cs
+++ b/Jellyfin.Api/Controllers/TrickplayController.cs
@@ -5,6 +5,8 @@ using System.Text;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model;
@@ -84,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
[FromRoute, Required] int index,
[FromQuery] Guid? mediaSourceId)
{
- var item = _libraryManager.GetItemById(mediaSourceId ?? itemId);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
if (item is null)
{
return NotFound();
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index 3d84b61bf..426402667 100644
--- a/Jellyfin.Api/Controllers/TvShowsController.cs
+++ b/Jellyfin.Api/Controllers/TvShowsController.cs
@@ -231,20 +231,22 @@ public class TvShowsController : BaseJellyfinApiController
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(User)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
+ var shouldIncludeMissingEpisodes = (user is not null && user.DisplayMissingEpisodes) || User.GetIsApiKey();
if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
{
- var item = _libraryManager.GetItemById(seasonId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value);
if (item is not Season seasonItem)
{
return NotFound("No season exists with Id " + seasonId);
}
- episodes = seasonItem.GetEpisodes(user, dtoOptions);
+ episodes = seasonItem.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes);
}
else if (season.HasValue) // Season number was supplied. Get episodes by season number
{
- if (_libraryManager.GetItemById(seriesId) is not Series series)
+ var series = _libraryManager.GetItemById<Series>(seriesId);
+ if (series is null)
{
return NotFound("Series not found");
}
@@ -255,16 +257,16 @@ public class TvShowsController : BaseJellyfinApiController
episodes = seasonItem is null ?
new List<BaseItem>()
- : ((Season)seasonItem).GetEpisodes(user, dtoOptions);
+ : ((Season)seasonItem).GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes);
}
else // No season number or season id was supplied. Returning all episodes.
{
- if (_libraryManager.GetItemById(seriesId) is not Series series)
+ if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series)
{
return NotFound("Series not found");
}
- episodes = series.GetEpisodes(user, dtoOptions).ToList();
+ episodes = series.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes).ToList();
}
// Filter after the fact in case the ui doesn't want them
@@ -342,13 +344,13 @@ public class TvShowsController : BaseJellyfinApiController
var user = userId.IsNullOrEmpty()
? null
: _userManager.GetUserById(userId.Value);
-
- if (_libraryManager.GetItemById(seriesId) is not Series series)
+ var item = _libraryManager.GetItemById<Series>(seriesId, user);
+ if (item is null)
{
- return NotFound("Series not found");
+ return NotFound();
}
- var seasons = series.GetItemList(new InternalItemsQuery(user)
+ var seasons = item.GetItemList(new InternalItemsQuery(user)
{
IsMissing = isMissing,
IsSpecialSeason = isSpecialSeason,
diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs
index db78e9946..fe7353496 100644
--- a/Jellyfin.Api/Controllers/UniversalAudioController.cs
+++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs
@@ -9,12 +9,15 @@ using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.StreamingDtos;
using Jellyfin.Data.Enums;
+using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Streaming;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -33,6 +36,7 @@ public class UniversalAudioController : BaseJellyfinApiController
private readonly MediaInfoHelper _mediaInfoHelper;
private readonly AudioHelper _audioHelper;
private readonly DynamicHlsHelper _dynamicHlsHelper;
+ private readonly IUserManager _userManager;
/// <summary>
/// Initializes a new instance of the <see cref="UniversalAudioController"/> class.
@@ -42,18 +46,21 @@ public class UniversalAudioController : BaseJellyfinApiController
/// <param name="mediaInfoHelper">Instance of <see cref="MediaInfoHelper"/>.</param>
/// <param name="audioHelper">Instance of <see cref="AudioHelper"/>.</param>
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
public UniversalAudioController(
ILibraryManager libraryManager,
ILogger<UniversalAudioController> logger,
MediaInfoHelper mediaInfoHelper,
AudioHelper audioHelper,
- DynamicHlsHelper dynamicHlsHelper)
+ DynamicHlsHelper dynamicHlsHelper,
+ IUserManager userManager)
{
_libraryManager = libraryManager;
_logger = logger;
_mediaInfoHelper = mediaInfoHelper;
_audioHelper = audioHelper;
_dynamicHlsHelper = dynamicHlsHelper;
+ _userManager = userManager;
}
/// <summary>
@@ -75,16 +82,19 @@ public class UniversalAudioController : BaseJellyfinApiController
/// <param name="maxAudioSampleRate">Optional. The maximum audio sample rate.</param>
/// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
/// <param name="enableRemoteMedia">Optional. Whether to enable remote media.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
/// <param name="enableRedirection">Whether to enable redirection. Defaults to true.</param>
/// <response code="200">Audio stream returned.</response>
/// <response code="302">Redirected to remote audio stream.</response>
+ /// <response code="404">Item not found.</response>
/// <returns>A <see cref="Task"/> containing the audio file.</returns>
[HttpGet("Audio/{itemId}/universal")]
[HttpHead("Audio/{itemId}/universal", Name = "HeadUniversalAudioStream")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status302Found)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesAudioFile]
public async Task<ActionResult> GetUniversalAudioStream(
[FromRoute, Required] Guid itemId,
@@ -103,25 +113,35 @@ public class UniversalAudioController : BaseJellyfinApiController
[FromQuery] int? maxAudioSampleRate,
[FromQuery] int? maxAudioBitDepth,
[FromQuery] bool? enableRemoteMedia,
+ [FromQuery] bool enableAudioVbrEncoding = true,
[FromQuery] bool breakOnNonKeyFrames = false,
[FromQuery] bool enableRedirection = true)
{
- var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
userId = RequestHelpers.GetUserId(User, userId);
+ var user = userId.IsNullOrEmpty()
+ ? null
+ : _userManager.GetUserById(userId.Value);
+ var item = _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
+ var deviceProfile = GetDeviceProfile(container, transcodingContainer, audioCodec, transcodingProtocol, breakOnNonKeyFrames, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, maxAudioChannels);
_logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", deviceProfile);
var info = await _mediaInfoHelper.GetPlaybackInfo(
- itemId,
- userId,
+ item,
+ user,
mediaSourceId)
.ConfigureAwait(false);
// set device specific data
- var item = _libraryManager.GetItemById(itemId);
-
foreach (var sourceInfo in info.MediaSources)
{
+ sourceInfo.TranscodingContainer = transcodingContainer;
+ sourceInfo.TranscodingSubProtocol = transcodingProtocol ?? sourceInfo.TranscodingSubProtocol;
_mediaInfoHelper.SetDeviceSpecificData(
item,
sourceInfo,
@@ -156,6 +176,8 @@ public class UniversalAudioController : BaseJellyfinApiController
return Redirect(mediaSource.Path);
}
+ // This one is currently very misleading as the SupportsDirectStream actually means "can direct play"
+ // The definition of DirectStream also seems changed during development
var isStatic = mediaSource.SupportsDirectStream;
if (!isStatic && mediaSource.TranscodingSubProtocol == MediaStreamProtocol.hls)
{
@@ -163,20 +185,25 @@ public class UniversalAudioController : BaseJellyfinApiController
// ffmpeg option -> file extension
// mpegts -> ts
// fmp4 -> mp4
- // TODO: remove this when we switch back to the segment muxer
var supportedHlsContainers = new[] { "ts", "mp4" };
+ // fallback to mpegts if device reports some weird value unsupported by hls
+ var requestedSegmentContainer = Array.Exists(
+ supportedHlsContainers,
+ element => string.Equals(element, transcodingContainer, StringComparison.OrdinalIgnoreCase)) ? transcodingContainer : "ts";
+ var segmentContainer = Array.Exists(
+ supportedHlsContainers,
+ element => string.Equals(element, mediaSource.TranscodingContainer, StringComparison.OrdinalIgnoreCase)) ? mediaSource.TranscodingContainer : requestedSegmentContainer;
var dynamicHlsRequestDto = new HlsAudioRequestDto
{
Id = itemId,
Container = ".m3u8",
Static = isStatic,
PlaySessionId = info.PlaySessionId,
- // fallback to mpegts if device reports some weird value unsupported by hls
- SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts",
+ SegmentContainer = segmentContainer,
MediaSourceId = mediaSourceId,
DeviceId = deviceId,
- AudioCodec = audioCodec,
+ AudioCodec = mediaSource.TranscodeReasons == TranscodeReason.ContainerNotSupported ? "copy" : audioCodec,
EnableAutoStreamCopy = true,
AllowAudioStreamCopy = true,
AllowVideoStreamCopy = true,
@@ -194,7 +221,8 @@ public class UniversalAudioController : BaseJellyfinApiController
TranscodeReasons = mediaSource.TranscodeReasons == 0 ? null : mediaSource.TranscodeReasons.ToString(),
Context = EncodingContext.Static,
StreamOptions = new Dictionary<string, string>(),
- EnableAdaptiveBitrateStreaming = true
+ EnableAdaptiveBitrateStreaming = true,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType.Hls, dynamicHlsRequestDto, true)
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index c19ad33c8..421f1bfb5 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -77,8 +77,8 @@ public class UserLibraryController : BaseJellyfinApiController
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -86,20 +86,12 @@ public class UserLibraryController : BaseJellyfinApiController
var item = itemId.IsEmpty()
? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
var dtoOptions = new DtoOptions().AddClientFields(User);
@@ -133,8 +125,8 @@ public class UserLibraryController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<BaseItemDto> GetRootFolder([FromQuery] Guid? userId)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -172,8 +164,8 @@ public class UserLibraryController : BaseJellyfinApiController
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -181,20 +173,12 @@ public class UserLibraryController : BaseJellyfinApiController
var item = itemId.IsEmpty()
? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
var dtoOptions = new DtoOptions().AddClientFields(User);
var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
@@ -231,8 +215,8 @@ public class UserLibraryController : BaseJellyfinApiController
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -240,20 +224,12 @@ public class UserLibraryController : BaseJellyfinApiController
var item = itemId.IsEmpty()
? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
return MarkFavorite(user, item, true);
}
@@ -286,8 +262,8 @@ public class UserLibraryController : BaseJellyfinApiController
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -295,20 +271,12 @@ public class UserLibraryController : BaseJellyfinApiController
var item = itemId.IsEmpty()
? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
return MarkFavorite(user, item, false);
}
@@ -341,8 +309,8 @@ public class UserLibraryController : BaseJellyfinApiController
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -350,20 +318,12 @@ public class UserLibraryController : BaseJellyfinApiController
var item = itemId.IsEmpty()
? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
return UpdateUserItemRatingInternal(user, item, null);
}
@@ -398,8 +358,8 @@ public class UserLibraryController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId,
[FromQuery] bool? likes)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -407,20 +367,12 @@ public class UserLibraryController : BaseJellyfinApiController
var item = itemId.IsEmpty()
? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
return UpdateUserItemRatingInternal(user, item, likes);
}
@@ -455,8 +407,8 @@ public class UserLibraryController : BaseJellyfinApiController
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -464,20 +416,12 @@ public class UserLibraryController : BaseJellyfinApiController
var item = itemId.IsEmpty()
? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
var dtoOptions = new DtoOptions().AddClientFields(User);
if (item is IHasTrailers hasTrailers)
{
@@ -519,8 +463,8 @@ public class UserLibraryController : BaseJellyfinApiController
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
- var requestUserId = RequestHelpers.GetUserId(User, userId);
- var user = _userManager.GetUserById(requestUserId);
+ userId = RequestHelpers.GetUserId(User, userId);
+ var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
return NotFound();
@@ -528,20 +472,12 @@ public class UserLibraryController : BaseJellyfinApiController
var item = itemId.IsEmpty()
? _libraryManager.GetUserRootFolder()
- : _libraryManager.GetItemById(itemId);
-
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
if (item is null)
{
return NotFound();
}
- if (item is not UserRootFolder
- // Check the item is visible for the user
- && !item.IsVisible(user))
- {
- return Unauthorized($"{user.Username} is not permitted to access item {item.Name}.");
- }
-
var dtoOptions = new DtoOptions().AddClientFields(User);
return Ok(item
diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs
index bf3ce1d39..01da50d02 100644
--- a/Jellyfin.Api/Controllers/UserViewsController.cs
+++ b/Jellyfin.Api/Controllers/UserViewsController.cs
@@ -85,16 +85,11 @@ public class UserViewsController : BaseJellyfinApiController
var folders = _userViewManager.GetUserViews(query);
var dtoOptions = new DtoOptions().AddClientFields(User);
- var fields = dtoOptions.Fields.ToList();
-
- fields.Add(ItemFields.PrimaryImageAspectRatio);
- fields.Add(ItemFields.DisplayPreferencesId);
- dtoOptions.Fields = fields.ToArray();
+ dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId];
var user = _userManager.GetUserById(userId.Value);
- var dtos = folders.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user))
- .ToArray();
+ var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user));
return new QueryResult<BaseItemDto>(dtos);
}
diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
index 23b9ba46f..b67c6fdb7 100644
--- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
+++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
@@ -4,7 +4,10 @@ using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
+using Jellyfin.Api.Extensions;
+using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Extensions;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.AspNetCore.Http;
@@ -54,7 +57,7 @@ public class VideoAttachmentsController : BaseJellyfinApiController
{
try
{
- var item = _libraryManager.GetItemById(videoId);
+ var item = _libraryManager.GetItemById<BaseItem>(videoId, User.GetUserId());
if (item is null)
{
return NotFound();
diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs
index 380120032..7f9608378 100644
--- a/Jellyfin.Api/Controllers/VideosController.cs
+++ b/Jellyfin.Api/Controllers/VideosController.cs
@@ -7,7 +7,6 @@ using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
-using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
@@ -105,7 +104,11 @@ public class VideosController : BaseJellyfinApiController
? (userId.IsNullOrEmpty()
? _libraryManager.RootFolder
: _libraryManager.GetUserRootFolder())
- : _libraryManager.GetItemById(itemId);
+ : _libraryManager.GetItemById<BaseItem>(itemId, user);
+ if (item is null)
+ {
+ return NotFound();
+ }
var dtoOptions = new DtoOptions();
dtoOptions = dtoOptions.AddClientFields(User);
@@ -139,24 +142,23 @@ public class VideosController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteAlternateSources([FromRoute, Required] Guid itemId)
{
- var video = (Video)_libraryManager.GetItemById(itemId);
-
- if (video is null)
+ var item = _libraryManager.GetItemById<Video>(itemId, User.GetUserId());
+ if (item is null)
{
- return NotFound("The video either does not exist or the id does not belong to a video.");
+ return NotFound();
}
- if (video.LinkedAlternateVersions.Length == 0)
+ if (item.LinkedAlternateVersions.Length == 0)
{
- video = (Video?)_libraryManager.GetItemById(video.PrimaryVersionId);
+ item = _libraryManager.GetItemById<Video>(Guid.Parse(item.PrimaryVersionId));
}
- if (video is null)
+ if (item is null)
{
return NotFound();
}
- foreach (var link in video.GetLinkedAlternateVersions())
+ foreach (var link in item.GetLinkedAlternateVersions())
{
link.SetPrimaryVersionId(null);
link.LinkedAlternateVersions = Array.Empty<LinkedChild>();
@@ -164,9 +166,9 @@ public class VideosController : BaseJellyfinApiController
await link.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
}
- video.LinkedAlternateVersions = Array.Empty<LinkedChild>();
- video.SetPrimaryVersionId(null);
- await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
+ item.LinkedAlternateVersions = Array.Empty<LinkedChild>();
+ item.SetPrimaryVersionId(null);
+ await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
return NoContent();
}
@@ -184,8 +186,9 @@ public class VideosController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{
+ var userId = User.GetUserId();
var items = ids
- .Select(i => _libraryManager.GetItemById(i))
+ .Select(i => _libraryManager.GetItemById<BaseItem>(i, userId))
.OfType<Video>()
.OrderBy(i => i.Id)
.ToList();
@@ -303,6 +306,7 @@ public class VideosController : BaseJellyfinApiController
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
/// <param name="streamOptions">Optional. The streaming options.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Video stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("{itemId}/stream")]
@@ -360,7 +364,8 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
- [FromQuery] Dictionary<string, string> streamOptions)
+ [FromQuery] Dictionary<string, string> streamOptions,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
var isHeadRequest = Request.Method == System.Net.WebRequestMethods.Http.Head;
// CTS lifecycle is managed internally.
@@ -416,7 +421,8 @@ public class VideosController : BaseJellyfinApiController
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context ?? EncodingContext.Streaming,
- StreamOptions = streamOptions
+ StreamOptions = streamOptions,
+ EnableAudioVbrEncoding = enableAudioVbrEncoding
};
var state = await StreamingHelpers.GetStreamingState(
@@ -541,6 +547,7 @@ public class VideosController : BaseJellyfinApiController
/// <param name="videoStreamIndex">Optional. The index of the video stream to use. If omitted the first video stream will be used.</param>
/// <param name="context">Optional. The <see cref="EncodingContext"/>.</param>
/// <param name="streamOptions">Optional. The streaming options.</param>
+ /// <param name="enableAudioVbrEncoding">Optional. Whether to enable Audio Encoding.</param>
/// <response code="200">Video stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
[HttpGet("{itemId}/stream.{container}")]
@@ -598,7 +605,8 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
- [FromQuery] Dictionary<string, string> streamOptions)
+ [FromQuery] Dictionary<string, string> streamOptions,
+ [FromQuery] bool enableAudioVbrEncoding = true)
{
return GetVideoStream(
itemId,
@@ -651,6 +659,7 @@ public class VideosController : BaseJellyfinApiController
audioStreamIndex,
videoStreamIndex,
context,
- streamOptions);
+ streamOptions,
+ enableAudioVbrEncoding);
}
}
diff --git a/Jellyfin.Api/Extensions/DtoExtensions.cs b/Jellyfin.Api/Extensions/DtoExtensions.cs
index 7d9823c25..3d17dbda1 100644
--- a/Jellyfin.Api/Extensions/DtoExtensions.cs
+++ b/Jellyfin.Api/Extensions/DtoExtensions.cs
@@ -43,11 +43,7 @@ public static class DtoExtensions
client.Contains("media center", StringComparison.OrdinalIgnoreCase) ||
client.Contains("classic", StringComparison.OrdinalIgnoreCase))
{
- int oldLen = dtoOptions.Fields.Count;
- var arr = new ItemFields[oldLen + 1];
- dtoOptions.Fields.CopyTo(arr, 0);
- arr[oldLen] = ItemFields.RecursiveItemCount;
- dtoOptions.Fields = arr;
+ dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.RecursiveItemCount];
}
}
@@ -61,11 +57,7 @@ public static class DtoExtensions
client.Contains("samsung", StringComparison.OrdinalIgnoreCase) ||
client.Contains("androidtv", StringComparison.OrdinalIgnoreCase))
{
- int oldLen = dtoOptions.Fields.Count;
- var arr = new ItemFields[oldLen + 1];
- dtoOptions.Fields.CopyTo(arr, 0);
- arr[oldLen] = ItemFields.ChildCount;
- dtoOptions.Fields = arr;
+ dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.ChildCount];
}
}
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
index e5e4356f8..ba92d811c 100644
--- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
+++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
@@ -151,6 +151,14 @@ public class DynamicHlsHelper
var queryString = _httpContextAccessor.HttpContext.Request.QueryString.ToString();
+ // from universal audio service, need to override the AudioCodec when the actual request differs from original query
+ if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
+ {
+ var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
+ newQuery["AudioCodec"] = state.OutputAudioCodec;
+ queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
+ }
+
// from universal audio service
if (!string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
&& !queryString.Contains("SegmentContainer", StringComparison.OrdinalIgnoreCase))
@@ -725,6 +733,21 @@ public class DynamicHlsHelper
return HlsCodecStringHelpers.GetAv1String(profile, level, false, bitDepth);
}
+ // VP9 HLS is for video remuxing only, everything is probed from the original video
+ if (string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
+ {
+ var width = state.VideoStream.Width ?? 0;
+ var height = state.VideoStream.Height ?? 0;
+ var framerate = state.VideoStream.AverageFrameRate ?? 30;
+ var bitDepth = state.VideoStream.BitDepth ?? 8;
+ return HlsCodecStringHelpers.GetVp9String(
+ width,
+ height,
+ state.VideoStream.PixelFormat,
+ framerate,
+ bitDepth);
+ }
+
return string.Empty;
}
diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
index 5eec1b0ca..d0bfa1fbe 100644
--- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
+++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
@@ -183,6 +183,68 @@ public static class HlsCodecStringHelpers
}
/// <summary>
+ /// Gets a VP9 codec string.
+ /// </summary>
+ /// <param name="width">Video width.</param>
+ /// <param name="height">Video height.</param>
+ /// <param name="pixelFormat">Video pixel format.</param>
+ /// <param name="framerate">Video framerate.</param>
+ /// <param name="bitDepth">Video bitDepth.</param>
+ /// <returns>The VP9 codec string.</returns>
+ public static string GetVp9String(int width, int height, string pixelFormat, float framerate, int bitDepth)
+ {
+ // refer: https://www.webmproject.org/vp9/mp4/
+ StringBuilder result = new StringBuilder("vp09", 13);
+
+ var profileString = pixelFormat switch
+ {
+ "yuv420p" => "00",
+ "yuvj420p" => "00",
+ "yuv422p" => "01",
+ "yuv444p" => "01",
+ "yuv420p10le" => "02",
+ "yuv420p12le" => "02",
+ "yuv422p10le" => "03",
+ "yuv422p12le" => "03",
+ "yuv444p10le" => "03",
+ "yuv444p12le" => "03",
+ _ => "00"
+ };
+
+ var lumaPictureSize = width * height;
+ var lumaSampleRate = lumaPictureSize * framerate;
+ var levelString = lumaPictureSize switch
+ {
+ <= 0 => "00",
+ <= 36864 => "10",
+ <= 73728 => "11",
+ <= 122880 => "20",
+ <= 245760 => "21",
+ <= 552960 => "30",
+ <= 983040 => "31",
+ <= 2228224 => lumaSampleRate <= 83558400 ? "40" : "41",
+ <= 8912896 => lumaSampleRate <= 311951360 ? "50" : (lumaSampleRate <= 588251136 ? "51" : "52"),
+ <= 35651584 => lumaSampleRate <= 1176502272 ? "60" : (lumaSampleRate <= 4706009088 ? "61" : "62"),
+ _ => "00" // This should not happen
+ };
+
+ if (bitDepth != 8
+ && bitDepth != 10
+ && bitDepth != 12)
+ {
+ // Default to 8 bits
+ bitDepth = 8;
+ }
+
+ result.Append('.').Append(profileString).Append('.').Append(levelString);
+ var bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture);
+ result.Append('.')
+ .Append(bitDepthD2);
+
+ return result.ToString();
+ }
+
+ /// <summary>
/// Gets an AV1 codec string.
/// </summary>
/// <param name="profile">AV1 profile.</param>
@@ -192,7 +254,7 @@ public static class HlsCodecStringHelpers
/// <returns>The AV1 codec string.</returns>
public static string GetAv1String(string? profile, int level, bool tierFlag, int bitDepth)
{
- // https://aomedia.org/av1/specification/annex-a/
+ // https://aomediacodec.github.io/av1-isobmff/#codecsparam
// FORMAT: [codecTag].[profile].[level][tier].[bitDepth]
StringBuilder result = new StringBuilder("av01", 13);
@@ -214,8 +276,7 @@ public static class HlsCodecStringHelpers
result.Append(".0");
}
- if (level <= 0
- || level > 31)
+ if (level is <= 0 or > 31)
{
// Default to the maximum defined level 6.3
level = 19;
@@ -230,7 +291,8 @@ public static class HlsCodecStringHelpers
}
result.Append('.')
- .Append(level)
+ // Needed to pad it double digits; otherwise, browsers will reject the stream.
+ .AppendFormat(CultureInfo.InvariantCulture, "{0:D2}", level)
.Append(tierFlag ? 'H' : 'M');
string bitDepthD2 = bitDepth.ToString("D2", CultureInfo.InvariantCulture);
diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
index 6a24ad32a..212d678a8 100644
--- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs
+++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
@@ -24,6 +24,7 @@ using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Session;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Api.Helpers;
@@ -76,21 +77,17 @@ public class MediaInfoHelper
/// <summary>
/// Get playback info.
/// </summary>
- /// <param name="id">Item id.</param>
- /// <param name="userId">User Id.</param>
+ /// <param name="item">The item.</param>
+ /// <param name="user">The user.</param>
/// <param name="mediaSourceId">Media source id.</param>
/// <param name="liveStreamId">Live stream id.</param>
/// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns>
public async Task<PlaybackInfoResponse> GetPlaybackInfo(
- Guid id,
- Guid? userId,
+ BaseItem item,
+ User? user,
string? mediaSourceId = null,
string? liveStreamId = null)
{
- var user = userId.IsNullOrEmpty()
- ? null
- : _userManager.GetUserById(userId.Value);
- var item = _libraryManager.GetItemById(id);
var result = new PlaybackInfoResponse();
MediaSourceInfo[] mediaSources;
@@ -402,7 +399,8 @@ public class MediaInfoHelper
if (profile is not null)
{
- var item = _libraryManager.GetItemById(request.ItemId);
+ var item = _libraryManager.GetItemById<BaseItem>(request.ItemId)
+ ?? throw new ResourceNotFoundException();
SetDeviceSpecificData(
item,
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index 429e97213..a3d7f471e 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -117,10 +117,15 @@ public static class RequestHelpers
return user.EnableUserPreferenceAccess;
}
- internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext)
+ internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null)
{
- var userId = httpContext.User.GetUserId();
- var user = userManager.GetUserById(userId);
+ userId ??= httpContext.User.GetUserId();
+ User? user = null;
+ if (!userId.IsNullOrEmpty())
+ {
+ user = userManager.GetUserById(userId.Value);
+ }
+
var session = await sessionManager.LogSessionActivity(
httpContext.User.GetClient(),
httpContext.User.GetVersion(),
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index bfe71fd87..535ef27c3 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -11,6 +11,7 @@ using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Streaming;
@@ -18,6 +19,7 @@ using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Net.Http.Headers;
namespace Jellyfin.Api.Helpers;
@@ -107,7 +109,8 @@ public static class StreamingHelpers
?? state.SupportedSubtitleCodecs.FirstOrDefault();
}
- var item = libraryManager.GetItemById(streamingRequest.Id);
+ var item = libraryManager.GetItemById<BaseItem>(streamingRequest.Id)
+ ?? throw new ResourceNotFoundException();
state.IsInputVideo = item.MediaType == MediaType.Video;
@@ -125,7 +128,7 @@ public static class StreamingHelpers
if (mediaSource is null)
{
- var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false);
+ var mediaSources = await mediaSourceManager.GetPlaybackMediaSources(libraryManager.GetItemById<BaseItem>(streamingRequest.Id), null, false, false, cancellationToken).ConfigureAwait(false);
mediaSource = string.IsNullOrEmpty(streamingRequest.MediaSourceId)
? mediaSources[0]
@@ -139,6 +142,25 @@ public static class StreamingHelpers
}
else
{
+ // Enforce more restrictive transcoding profile for LiveTV due to compatability reasons
+ // Cap the MaxStreamingBitrate to 30Mbps, because we are unable to reliably probe source bitrate,
+ // which will cause the client to request extremely high bitrate that may fail the player/encoder
+ streamingRequest.VideoBitRate = streamingRequest.VideoBitRate > 30000000 ? 30000000 : streamingRequest.VideoBitRate;
+
+ if (streamingRequest.SegmentContainer is not null)
+ {
+ // Remove all fmp4 transcoding profiles, because it causes playback error and/or A/V sync issues
+ // Notably: Some channels won't play on FireFox and LG webOS
+ // Some channels from HDHomerun will experience A/V sync issues
+ streamingRequest.SegmentContainer = "ts";
+ streamingRequest.VideoCodec = "h264";
+ streamingRequest.AudioCodec = "aac";
+ state.SupportedVideoCodecs = ["h264"];
+ state.Request.VideoCodec = "h264";
+ state.SupportedAudioCodecs = ["aac"];
+ state.Request.AudioCodec = "aac";
+ }
+
var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
mediaSource = liveStreamInfo.Item1;
state.DirectStreamProvider = liveStreamInfo.Item2;
@@ -163,6 +185,9 @@ public static class StreamingHelpers
}
var outputAudioCodec = streamingRequest.AudioCodec;
+ state.OutputAudioCodec = outputAudioCodec;
+ state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
+ state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
if (EncodingHelper.LosslessAudioCodecs.Contains(outputAudioCodec))
{
state.OutputAudioBitrate = state.AudioStream.BitRate ?? 0;
@@ -177,10 +202,6 @@ public static class StreamingHelpers
containerInternal = ".pcm";
}
- state.OutputAudioCodec = outputAudioCodec;
- state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
- state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec);
-
if (state.VideoRequest is not null)
{
state.OutputVideoCodec = state.Request.VideoCodec;
diff --git a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs
index 94ffc5238..7a549aada 100644
--- a/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs
+++ b/Jellyfin.Api/Models/LibraryStructureDto/MediaPathDto.cs
@@ -12,7 +12,7 @@ public class MediaPathDto
/// Gets or sets the name of the library.
/// </summary>
[Required]
- public string? Name { get; set; }
+ public required string Name { get; set; }
/// <summary>
/// Gets or sets the path to add.
diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
index bdc488871..3cbdd031a 100644
--- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
+++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Text.Json.Serialization;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json.Converters;
+using MediaBrowser.Model.Entities;
namespace Jellyfin.Api.Models.PlaylistDtos;
@@ -14,13 +15,13 @@ public class CreatePlaylistDto
/// <summary>
/// Gets or sets the name of the new playlist.
/// </summary>
- public string? Name { get; set; }
+ public required string Name { get; set; }
/// <summary>
/// Gets or sets item ids to add to the playlist.
/// </summary>
[JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
- public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>();
+ public IReadOnlyList<Guid> Ids { get; set; } = [];
/// <summary>
/// Gets or sets the user id.
@@ -31,4 +32,14 @@ public class CreatePlaylistDto
/// Gets or sets the media type.
/// </summary>
public MediaType? MediaType { get; set; }
+
+ /// <summary>
+ /// Gets or sets the playlist users.
+ /// </summary>
+ public IReadOnlyList<PlaylistUserPermissions> Users { get; set; } = [];
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the playlist is public.
+ /// </summary>
+ public bool IsPublic { get; set; } = true;
}
diff --git a/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs
new file mode 100644
index 000000000..80e20995c
--- /dev/null
+++ b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistDto.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using Jellyfin.Extensions.Json.Converters;
+using MediaBrowser.Model.Entities;
+
+namespace Jellyfin.Api.Models.PlaylistDtos;
+
+/// <summary>
+/// Update existing playlist dto. Fields set to `null` will not be updated and keep their current values.
+/// </summary>
+public class UpdatePlaylistDto
+{
+ /// <summary>
+ /// Gets or sets the name of the new playlist.
+ /// </summary>
+ public string? Name { get; set; }
+
+ /// <summary>
+ /// Gets or sets item ids of the playlist.
+ /// </summary>
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<Guid>? Ids { get; set; }
+
+ /// <summary>
+ /// Gets or sets the playlist users.
+ /// </summary>
+ public IReadOnlyList<PlaylistUserPermissions>? Users { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the playlist is public.
+ /// </summary>
+ public bool? IsPublic { get; set; }
+}
diff --git a/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs
new file mode 100644
index 000000000..60467b5e7
--- /dev/null
+++ b/Jellyfin.Api/Models/PlaylistDtos/UpdatePlaylistUserDto.cs
@@ -0,0 +1,12 @@
+namespace Jellyfin.Api.Models.PlaylistDtos;
+
+/// <summary>
+/// Update existing playlist user dto. Fields set to `null` will not be updated and keep their current values.
+/// </summary>
+public class UpdatePlaylistUserDto
+{
+ /// <summary>
+ /// Gets or sets a value indicating whether the user can edit the playlist.
+ /// </summary>
+ public bool? CanEdit { get; set; }
+}
diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj
index 75912abf0..3adca6ef2 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.9.0</VersionPrefix>
+ <VersionPrefix>10.10.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
index 5e5d52b6b..d8d1b6fa8 100644
--- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
+++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs
@@ -179,7 +179,7 @@ namespace Jellyfin.Server.Implementations.Devices
.SelectMany(d => dbContext.DeviceOptions.Where(o => o.DeviceId == d.DeviceId).DefaultIfEmpty(), (d, o) => new { Device = d, Options = o })
.AsAsyncEnumerable();
- if (userId.HasValue)
+ if (!userId.IsNullOrEmpty())
{
var user = _userManager.GetUserById(userId.Value);
if (user is null)
diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
index bb8d4dd14..a88989840 100644
--- a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
+++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs
@@ -16,23 +16,28 @@ public static class ServiceCollectionExtensions
/// Adds the <see cref="IDbContextFactory{TContext}"/> interface to the service collection with second level caching enabled.
/// </summary>
/// <param name="serviceCollection">An instance of the <see cref="IServiceCollection"/> interface.</param>
+ /// <param name="disableSecondLevelCache">Whether second level cache disabled..</param>
/// <returns>The updated service collection.</returns>
- public static IServiceCollection AddJellyfinDbContext(this IServiceCollection serviceCollection)
+ public static IServiceCollection AddJellyfinDbContext(this IServiceCollection serviceCollection, bool disableSecondLevelCache)
{
- serviceCollection.AddEFSecondLevelCache(options =>
- options.UseMemoryCacheProvider()
- .CacheAllQueries(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(10))
- .DisableLogging(true)
- .UseCacheKeyPrefix("EF_")
- // Don't cache null values. Remove this optional setting if it's not necessary.
- .SkipCachingResults(result =>
- result.Value is null || (result.Value is EFTableRows rows && rows.RowsCount == 0)));
+ if (!disableSecondLevelCache)
+ {
+ serviceCollection.AddEFSecondLevelCache(options =>
+ options.UseMemoryCacheProvider()
+ .CacheAllQueries(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(10))
+ .UseCacheKeyPrefix("EF_")
+ // Don't cache null values. Remove this optional setting if it's not necessary.
+ .SkipCachingResults(result => result.Value is null or EFTableRows { RowsCount: 0 }));
+ }
serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) =>
{
var applicationPaths = serviceProvider.GetRequiredService<IApplicationPaths>();
- opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}")
- .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>());
+ var dbOpt = opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}");
+ if (!disableSecondLevelCache)
+ {
+ dbOpt.AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>());
+ }
});
return serviceCollection;
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index fed5dab69..bb32b7c20 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -81,6 +81,12 @@ public class TrickplayManager : ITrickplayManager
_logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
var options = _config.Configuration.TrickplayOptions;
+ 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();
@@ -106,18 +112,11 @@ public class TrickplayManager : ITrickplayManager
}
var imgTempDir = string.Empty;
- var outputDir = GetTrickplayDirectory(video, width);
using (await _resourcePool.LockAsync(cancellationToken).ConfigureAwait(false))
{
try
{
- if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(width))
- {
- _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting.", video.Id);
- return;
- }
-
// Extract images
// Note: Media sources under parent items exist as their own video/item as well. Only use this video stream for trickplay.
var mediaSource = video.GetMediaSources(false).Find(source => Guid.Parse(source.Id).Equals(video.Id));
@@ -129,22 +128,47 @@ public class TrickplayManager : ITrickplayManager
}
var mediaPath = mediaSource.Path;
+ if (!File.Exists(mediaPath))
+ {
+ _logger.LogWarning("Media not found at {Path} for item {ItemID}", mediaPath, video.Id);
+ return;
+ }
+
+ // The width has to be even, otherwise a lot of filters will not be able to sample it
+ var actualWidth = 2 * (width / 2);
+
+ // Force using the video width when the trickplay setting has a too large width
+ if (mediaSource.VideoStream.Width is not null && mediaSource.VideoStream.Width < width)
+ {
+ _logger.LogWarning("Video width {VideoWidth} is smaller than trickplay setting {TrickPlayWidth}, using video width for thumbnails", mediaSource.VideoStream.Width, width);
+ actualWidth = 2 * ((int)mediaSource.VideoStream.Width / 2);
+ }
+
+ var outputDir = GetTrickplayDirectory(video, actualWidth);
+
+ if (!replace && Directory.Exists(outputDir) && (await GetTrickplayResolutions(video.Id).ConfigureAwait(false)).ContainsKey(actualWidth))
+ {
+ _logger.LogDebug("Found existing trickplay files for {ItemId}. Exiting", video.Id);
+ return;
+ }
+
var mediaStream = mediaSource.VideoStream;
var container = mediaSource.Container;
- _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", width, mediaPath, video.Id);
+ _logger.LogInformation("Creating trickplay files at {Width} width, for {Path} [ID: {ItemId}]", actualWidth, mediaPath, video.Id);
imgTempDir = await _mediaEncoder.ExtractVideoImagesOnIntervalAccelerated(
mediaPath,
container,
mediaSource,
mediaStream,
- width,
+ actualWidth,
TimeSpan.FromMilliseconds(options.Interval),
options.EnableHwAcceleration,
options.EnableHwEncoding,
options.ProcessThreads,
options.Qscale,
options.ProcessPriority,
+ options.EnableKeyFrameOnlyExtraction,
_encodingHelper,
cancellationToken).ConfigureAwait(false);
@@ -159,7 +183,7 @@ public class TrickplayManager : ITrickplayManager
.ToList();
// Create tiles
- var trickplayInfo = CreateTiles(images, width, options, outputDir);
+ var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir);
// Save tiles info
try
@@ -207,7 +231,7 @@ public class TrickplayManager : ITrickplayManager
throw new ArgumentException("Can't create trickplay from 0 images.");
}
- var workDir = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid().ToString("N"));
+ var workDir = Path.Combine(_appPaths.TempDirectory, "trickplay_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
var trickplayInfo = new TrickplayInfo
@@ -250,7 +274,7 @@ public class TrickplayManager : ITrickplayManager
}
// Update bitrate
- var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tilePath).Length * 8 / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000));
+ var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000m));
trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
}
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 41f1ac351..5753e75c9 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -1,7 +1,7 @@
#pragma warning disable CA1307
-#pragma warning disable CA1309 // Use ordinal string comparison - EF can't translate this
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -47,6 +47,8 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly IDictionary<Guid, User> _users;
+
/// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class.
/// </summary>
@@ -84,30 +86,29 @@ namespace Jellyfin.Server.Implementations.Users
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
+
+ _users = new ConcurrentDictionary<Guid, User>();
+ using var dbContext = _dbProvider.CreateDbContext();
+ foreach (var user in dbContext.Users
+ .AsSplitQuery()
+ .Include(user => user.Permissions)
+ .Include(user => user.Preferences)
+ .Include(user => user.AccessSchedules)
+ .Include(user => user.ProfileImage)
+ .AsEnumerable())
+ {
+ _users.Add(user.Id, user);
+ }
}
/// <inheritdoc/>
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
/// <inheritdoc/>
- public IEnumerable<User> Users
- {
- get
- {
- using var dbContext = _dbProvider.CreateDbContext();
- return GetUsersInternal(dbContext).ToList();
- }
- }
+ public IEnumerable<User> Users => _users.Values;
/// <inheritdoc/>
- public IEnumerable<Guid> UsersIds
- {
- get
- {
- using var dbContext = _dbProvider.CreateDbContext();
- return dbContext.Users.Select(u => u.Id).ToList();
- }
- }
+ public IEnumerable<Guid> UsersIds => _users.Keys;
// This is some regex that matches only on unicode "word" characters, as well as -, _ and @
// In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
@@ -123,8 +124,8 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Guid can't be empty", nameof(id));
}
- using var dbContext = _dbProvider.CreateDbContext();
- return GetUsersInternal(dbContext).FirstOrDefault(u => u.Id.Equals(id));
+ _users.TryGetValue(id, out var user);
+ return user;
}
/// <inheritdoc/>
@@ -135,9 +136,7 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Invalid username", nameof(name));
}
- using var dbContext = _dbProvider.CreateDbContext();
- return GetUsersInternal(dbContext)
- .FirstOrDefault(u => string.Equals(u.Username, name));
+ return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
}
/// <inheritdoc/>
@@ -202,6 +201,8 @@ namespace Jellyfin.Server.Implementations.Users
user.AddDefaultPermissions();
user.AddDefaultPreferences();
+ _users.Add(user.Id, user);
+
return user;
}
@@ -236,46 +237,40 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public async Task DeleteUserAsync(Guid userId)
{
- var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ if (!_users.TryGetValue(userId, out var user))
+ {
+ throw new ResourceNotFoundException(nameof(userId));
+ }
- await using (dbContext.ConfigureAwait(false))
+ if (_users.Count == 1)
{
- var user = await dbContext.Users
- .AsSingleQuery()
- .Include(u => u.Permissions)
- .FirstOrDefaultAsync(u => u.Id.Equals(userId))
- .ConfigureAwait(false);
- if (user is null)
- {
- throw new ResourceNotFoundException(nameof(userId));
- }
+ throw new InvalidOperationException(string.Format(
+ CultureInfo.InvariantCulture,
+ "The user '{0}' cannot be deleted because there must be at least one user in the system.",
+ user.Username));
+ }
- if (await dbContext.Users.CountAsync().ConfigureAwait(false) == 1)
- {
- throw new InvalidOperationException(string.Format(
+ if (user.HasPermission(PermissionKind.IsAdministrator)
+ && Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
+ {
+ throw new ArgumentException(
+ string.Format(
CultureInfo.InvariantCulture,
- "The user '{0}' cannot be deleted because there must be at least one user in the system.",
- user.Username));
- }
-
- if (user.HasPermission(PermissionKind.IsAdministrator)
- && await dbContext.Users
- .CountAsync(u => u.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value))
- .ConfigureAwait(false) == 1)
- {
- throw new ArgumentException(
- string.Format(
- CultureInfo.InvariantCulture,
- "The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
- user.Username),
- nameof(userId));
- }
+ "The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
+ user.Username),
+ nameof(userId));
+ }
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
-
- await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
}
+
+ _users.Remove(userId);
+
+ await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -542,23 +537,23 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc />
public async Task InitializeAsync()
{
- var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
- await using (dbContext.ConfigureAwait(false))
+ // TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
+ if (_users.Any())
{
- // TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
- if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
- {
- return;
- }
+ return;
+ }
- var defaultName = Environment.UserName;
- if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
- {
- defaultName = "MyJellyfinUser";
- }
+ var defaultName = Environment.UserName;
+ if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
+ {
+ defaultName = "MyJellyfinUser";
+ }
- _logger.LogWarning("No users, creating one with username {UserName}", defaultName);
+ _logger.LogWarning("No users, creating one with username {UserName}", defaultName);
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
+ {
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
newUser.SetPermission(PermissionKind.IsAdministrator, true);
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
@@ -605,9 +600,12 @@ namespace Jellyfin.Server.Implementations.Users
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- var user = await GetUsersInternal(dbContext)
- .FirstOrDefaultAsync(u => u.Id.Equals(userId))
- .ConfigureAwait(false)
+ var user = dbContext.Users
+ .Include(u => u.Permissions)
+ .Include(u => u.Preferences)
+ .Include(u => u.AccessSchedules)
+ .Include(u => u.ProfileImage)
+ .FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
user.SubtitleMode = config.SubtitleMode;
@@ -635,6 +633,7 @@ namespace Jellyfin.Server.Implementations.Users
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
dbContext.Update(user);
+ _users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
@@ -645,9 +644,12 @@ namespace Jellyfin.Server.Implementations.Users
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
- var user = await GetUsersInternal(dbContext)
- .FirstOrDefaultAsync(u => u.Id.Equals(userId))
- .ConfigureAwait(false)
+ var user = dbContext.Users
+ .Include(u => u.Permissions)
+ .Include(u => u.Preferences)
+ .Include(u => u.AccessSchedules)
+ .Include(u => u.ProfileImage)
+ .FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
@@ -708,6 +710,7 @@ namespace Jellyfin.Server.Implementations.Users
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
dbContext.Update(user);
+ _users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
@@ -728,6 +731,7 @@ namespace Jellyfin.Server.Implementations.Users
}
user.ProfileImage = null;
+ _users[user.Id] = user;
}
internal static void ThrowIfInvalidUsername(string name)
@@ -874,15 +878,8 @@ namespace Jellyfin.Server.Implementations.Users
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{
dbContext.Users.Update(user);
+ _users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
-
- private IQueryable<User> GetUsersInternal(JellyfinDbContext dbContext)
- => dbContext.Users
- .AsSplitQuery()
- .Include(user => user.Permissions)
- .Include(user => user.Preferences)
- .Include(user => user.AccessSchedules)
- .Include(user => user.ProfileImage);
}
}
diff --git a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs
index c9d5b54de..858df6728 100644
--- a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs
@@ -35,17 +35,18 @@ public static class WebHostBuilderExtensions
return builder
.UseKestrel((builderContext, options) =>
{
- var addresses = appHost.NetManager.GetAllBindInterfaces(true);
+ var addresses = appHost.NetManager.GetAllBindInterfaces(false);
bool flagged = false;
foreach (var netAdd in addresses)
{
- logger.LogInformation("Kestrel is listening on {Address}", IPAddress.IPv6Any.Equals(netAdd.Address) ? "All IPv6 addresses" : netAdd.Address);
+ var address = netAdd.Address;
+ logger.LogInformation("Kestrel is listening on {Address}", address.Equals(IPAddress.IPv6Any) ? "all interfaces" : address);
options.Listen(netAdd.Address, appHost.HttpPort);
if (appHost.ListenWithHttps)
{
options.Listen(
- netAdd.Address,
+ address,
appHost.HttpsPort,
listenOptions => listenOptions.UseHttps(appHost.Certificate));
}
@@ -54,7 +55,7 @@ public static class WebHostBuilderExtensions
try
{
options.Listen(
- netAdd.Address,
+ address,
appHost.HttpsPort,
listenOptions => listenOptions.UseHttps());
}
@@ -84,6 +85,6 @@ public static class WebHostBuilderExtensions
logger.LogInformation("Kestrel listening to unix socket {SocketPath}", socketPath);
}
})
- .UseStartup(_ => new Startup(appHost));
+ .UseStartup(_ => new Startup(appHost, startupConfig));
}
}
diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs
index 5311a30e4..5518d6ba8 100644
--- a/Jellyfin.Server/Helpers/StartupHelpers.cs
+++ b/Jellyfin.Server/Helpers/StartupHelpers.cs
@@ -60,6 +60,7 @@ public static class StartupHelpers
logger.LogInformation("Log directory path: {LogDirectoryPath}", appPaths.LogDirectoryPath);
logger.LogInformation("Config directory path: {ConfigurationDirectoryPath}", appPaths.ConfigurationDirectoryPath);
logger.LogInformation("Cache path: {CachePath}", appPaths.CachePath);
+ logger.LogInformation("Temp directory path: {TempDirPath}", appPaths.TempDirectory);
logger.LogInformation("Web resources path: {WebPath}", appPaths.WebPath);
logger.LogInformation("Application directory: {ApplicationPath}", appPaths.ProgramSystemPath);
}
diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs
index 44aa43044..81fecc9a1 100644
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ b/Jellyfin.Server/Migrations/MigrationRunner.cs
@@ -44,7 +44,8 @@ namespace Jellyfin.Server.Migrations
typeof(Routines.FixPlaylistOwner),
typeof(Routines.MigrateRatingLevels),
typeof(Routines.AddDefaultCastReceivers),
- typeof(Routines.UpdateDefaultPluginRepository)
+ typeof(Routines.UpdateDefaultPluginRepository),
+ typeof(Routines.FixAudioData),
};
/// <summary>
diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
index 75a6a6176..57a5c8a62 100644
--- a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
+++ b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs
@@ -32,24 +32,20 @@ public class AddDefaultCastReceivers : IMigrationRoutine
/// <inheritdoc />
public void Perform()
{
- // Only add if receiver list is empty.
- if (_serverConfigurationManager.Configuration.CastReceiverApplications.Length == 0)
- {
- _serverConfigurationManager.Configuration.CastReceiverApplications = new CastReceiverApplication[]
+ _serverConfigurationManager.Configuration.CastReceiverApplications =
+ [
+ new()
{
- new()
- {
- Id = "F007D354",
- Name = "Stable"
- },
- new()
- {
- Id = "6F511C87",
- Name = "Unstable"
- }
- };
-
- _serverConfigurationManager.SaveConfiguration();
- }
+ Id = "F007D354",
+ Name = "Stable"
+ },
+ new()
+ {
+ Id = "6F511C87",
+ Name = "Unstable"
+ }
+ ];
+
+ _serverConfigurationManager.SaveConfiguration();
}
}
diff --git a/Jellyfin.Server/Migrations/Routines/FixAudioData.cs b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
new file mode 100644
index 000000000..a20253369
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/FixAudioData.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Persistence;
+using MediaBrowser.Model.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+ /// <summary>
+ /// Fixes the data column of audio types to be deserializable.
+ /// </summary>
+ internal class FixAudioData : IMigrationRoutine
+ {
+ private const string DbFilename = "library.db";
+ private readonly ILogger<FixAudioData> _logger;
+ private readonly IServerApplicationPaths _applicationPaths;
+ private readonly IItemRepository _itemRepository;
+
+ public FixAudioData(
+ IServerApplicationPaths applicationPaths,
+ ILoggerFactory loggerFactory,
+ IItemRepository itemRepository)
+ {
+ _applicationPaths = applicationPaths;
+ _itemRepository = itemRepository;
+ _logger = loggerFactory.CreateLogger<FixAudioData>();
+ }
+
+ /// <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
+ {
+ IncludeItemTypes = [BaseItemKind.Audio],
+ });
+
+ while (startIndex < records)
+ {
+ var results = _itemRepository.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = [BaseItemKind.Audio],
+ StartIndex = startIndex,
+ Limit = 5000,
+ SkipDeserialization = true
+ })
+ .Cast<Audio>()
+ .ToList();
+
+ foreach (var audio in results)
+ {
+ var lyricMediaStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric).Select(s => s.Path).ToList();
+ if (lyricMediaStreams.Count > 0)
+ {
+ audio.HasLyrics = true;
+ audio.LyricFiles = lyricMediaStreams;
+ }
+ }
+
+ _itemRepository.SaveItems(results, CancellationToken.None);
+ startIndex += results.Count;
+ _logger.LogInformation("Backfilled data for {UpdatedRecords} of {TotalRecords} audio records", startIndex, records);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
index cf3182003..3655a610d 100644
--- a/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
+++ b/Jellyfin.Server/Migrations/Routines/FixPlaylistOwner.cs
@@ -54,12 +54,12 @@ internal class FixPlaylistOwner : IMigrationRoutine
foreach (var playlist in playlists)
{
var shares = playlist.Shares;
- if (shares.Length > 0)
+ if (shares.Count > 0)
{
var firstEditShare = shares.First(x => x.CanEdit);
- if (firstEditShare is not null && Guid.TryParse(firstEditShare.UserId, out var guid))
+ if (firstEditShare is not null)
{
- playlist.OwnerUserId = guid;
+ playlist.OwnerUserId = firstEditShare.UserId;
playlist.Shares = shares.Where(x => x != firstEditShare).ToArray();
playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
_playlistManager.SavePlaylistFile(playlist);
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
index 4fee88b68..808c5e33b 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs
@@ -67,7 +67,7 @@ namespace Jellyfin.Server.Migrations.Routines
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
{
connection.Open();
- var dbContext = _provider.CreateDbContext();
+ using var dbContext = _provider.CreateDbContext();
var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
index 9137ea234..52fb93d59 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
@@ -42,7 +42,7 @@ namespace Jellyfin.Server.Migrations.Routines
}
var libraryOptions = virtualFolder.LibraryOptions;
- var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(folderId);
+ 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/Program.cs b/Jellyfin.Server/Program.cs
index fd7696906..295fb8112 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -185,6 +185,7 @@ namespace Jellyfin.Server
}
catch (Exception ex)
{
+ _restartOnShutdown = false;
_logger.LogCritical(ex, "Error while starting server");
}
finally
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index e9fb3e4c2..2ff377403 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -40,15 +40,18 @@ namespace Jellyfin.Server
{
private readonly CoreAppHost _serverApplicationHost;
private readonly IServerConfigurationManager _serverConfigurationManager;
+ private readonly IConfiguration _startupConfig;
/// <summary>
/// Initializes a new instance of the <see cref="Startup" /> class.
/// </summary>
/// <param name="appHost">The server application host.</param>
- public Startup(CoreAppHost appHost)
+ /// <param name="startupConfig">The server startupConfig.</param>
+ public Startup(CoreAppHost appHost, IConfiguration startupConfig)
{
_serverApplicationHost = appHost;
_serverConfigurationManager = appHost.ConfigurationManager;
+ _startupConfig = startupConfig;
}
/// <summary>
@@ -67,7 +70,7 @@ namespace Jellyfin.Server
// 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();
+ services.AddJellyfinDbContext(_startupConfig.GetSqliteSecondLevelCacheDisabled());
services.AddJellyfinApiSwagger();
// configure custom legacy authentication
diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj
index 8ad626b41..c1945bf93 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.9.0</VersionPrefix>
+ <VersionPrefix>10.10.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Common/Net/NetworkConstants.cs b/MediaBrowser.Common/Net/NetworkConstants.cs
index b18058fa9..ccef5d271 100644
--- a/MediaBrowser.Common/Net/NetworkConstants.cs
+++ b/MediaBrowser.Common/Net/NetworkConstants.cs
@@ -59,6 +59,11 @@ public static class NetworkConstants
public static readonly IPNetwork IPv4RFC1918PrivateClassC = new IPNetwork(IPAddress.Parse("192.168.0.0"), 16);
/// <summary>
+ /// IPv4 Link-Local as defined in RFC 3927.
+ /// </summary>
+ public static readonly IPNetwork IPv4RFC3927LinkLocal = new IPNetwork(IPAddress.Parse("169.254.0.0"), 16);
+
+ /// <summary>
/// IPv6 loopback as defined in RFC 4291.
/// </summary>
public static readonly IPNetwork IPv6RFC4291Loopback = new IPNetwork(IPAddress.IPv6Loopback, 128);
diff --git a/MediaBrowser.Controller/Entities/AggregateFolder.cs b/MediaBrowser.Controller/Entities/AggregateFolder.cs
index b225f22df..40cdd6c91 100644
--- a/MediaBrowser.Controller/Entities/AggregateFolder.cs
+++ b/MediaBrowser.Controller/Entities/AggregateFolder.cs
@@ -155,11 +155,11 @@ namespace MediaBrowser.Controller.Entities
return base.GetNonCachedChildren(directoryService).Concat(_virtualChildren);
}
- protected override async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
+ protected override async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
{
ClearCache();
- await base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, refreshOptions, directoryService, cancellationToken)
+ await base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, allowRemoveRoot, refreshOptions, directoryService, cancellationToken)
.ConfigureAwait(false);
ClearCache();
diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs
index 237345206..a0aae8769 100644
--- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs
+++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs
@@ -169,8 +169,7 @@ namespace MediaBrowser.Controller.Entities.Audio
var childUpdateType = ItemUpdateType.None;
- // Refresh songs only and not m3u files in album folder
- foreach (var item in items.OfType<Audio>())
+ foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -183,14 +182,13 @@ namespace MediaBrowser.Controller.Entities.Audio
progress.Report(percent * 95);
}
- // get album LUFS
- LUFS = items.OfType<Audio>().Max(item => item.LUFS);
-
var parentRefreshOptions = refreshOptions;
if (childUpdateType > ItemUpdateType.None)
{
- parentRefreshOptions = new MetadataRefreshOptions(refreshOptions);
- parentRefreshOptions.MetadataRefreshMode = MetadataRefreshMode.FullRefresh;
+ parentRefreshOptions = new MetadataRefreshOptions(refreshOptions)
+ {
+ MetadataRefreshMode = MetadataRefreshMode.FullRefresh
+ };
}
// Refresh current item
diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
index 11cdf8444..1ab6c9706 100644
--- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
+++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs
@@ -110,7 +110,7 @@ namespace MediaBrowser.Controller.Entities.Audio
return base.IsSaveLocalMetadataEnabled();
}
- protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
+ protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
{
if (IsAccessedByName)
{
@@ -118,7 +118,7 @@ namespace MediaBrowser.Controller.Entities.Audio
return Task.CompletedTask;
}
- return base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, refreshOptions, directoryService, cancellationToken);
+ return base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, false, refreshOptions, directoryService, cancellationToken);
}
public override List<string> GetUserDataKeys()
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index ac9698ec9..7b6f364f7 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -135,7 +135,14 @@ namespace MediaBrowser.Controller.Entities
/// </summary>
/// <value>The LUFS Value.</value>
[JsonIgnore]
- public float LUFS { get; set; }
+ public float? LUFS { get; set; }
+
+ /// <summary>
+ /// Gets or sets the gain required for audio normalization.
+ /// </summary>
+ /// <value>The gain required for audio normalization.</value>
+ [JsonIgnore]
+ public float? NormalizationGain { get; set; }
/// <summary>
/// Gets or sets the channel identifier.
@@ -745,9 +752,6 @@ namespace MediaBrowser.Controller.Entities
public virtual bool SupportsAncestors => true;
[JsonIgnore]
- public virtual bool StopRefreshIfLocalMetadataFound => true;
-
- [JsonIgnore]
protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol;
[JsonIgnore]
@@ -833,7 +837,7 @@ namespace MediaBrowser.Controller.Entities
return CanDelete() && IsAuthorizedToDelete(user, allCollectionFolders);
}
- public bool CanDelete(User user)
+ public virtual bool CanDelete(User user)
{
var allCollectionFolders = LibraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();
@@ -1602,6 +1606,12 @@ namespace MediaBrowser.Controller.Entities
return false;
}
+ var parent = GetParents().FirstOrDefault() ?? this;
+ if (parent is UserRootFolder or AggregateFolder)
+ {
+ return true;
+ }
+
var allowedTagsPreference = user.GetPreference(PreferenceKind.AllowedTags);
if (allowedTagsPreference.Length != 0 && !allowedTagsPreference.Any(i => allTags.Contains(i, StringComparison.OrdinalIgnoreCase)))
{
@@ -1766,14 +1776,11 @@ namespace MediaBrowser.Controller.Entities
int curLen = current.Length;
if (curLen == 0)
{
- Studios = new[] { name };
+ Studios = [name];
}
else
{
- var newArr = new string[curLen + 1];
- current.CopyTo(newArr, 0);
- newArr[curLen] = name;
- Studios = newArr;
+ Studios = [..current, name];
}
}
}
@@ -1795,9 +1802,7 @@ namespace MediaBrowser.Controller.Entities
var genres = Genres;
if (!genres.Contains(name, StringComparison.OrdinalIgnoreCase))
{
- var list = genres.ToList();
- list.Add(name);
- Genres = list.ToArray();
+ Genres = [..genres, name];
}
}
@@ -1944,14 +1949,15 @@ namespace MediaBrowser.Controller.Entities
return;
}
- // Remove it from the item
- RemoveImage(info);
-
+ // Remove from file system
if (info.IsLocalFile)
{
FileSystem.DeleteFile(info.Path);
}
+ // Remove from item
+ RemoveImage(info);
+
await UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
}
@@ -1967,12 +1973,7 @@ namespace MediaBrowser.Controller.Entities
public void AddImage(ItemImageInfo image)
{
- var current = ImageInfos;
- var currentCount = current.Length;
- var newArr = new ItemImageInfo[currentCount + 1];
- current.CopyTo(newArr, 0);
- newArr[currentCount] = image;
- ImageInfos = newArr;
+ ImageInfos = [..ImageInfos, image];
}
public virtual Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
@@ -2496,11 +2497,6 @@ namespace MediaBrowser.Controller.Entities
return new[] { Id };
}
- public virtual List<ExternalUrl> GetRelatedUrls()
- {
- return new List<ExternalUrl>();
- }
-
public virtual double? GetRefreshProgress()
{
return null;
@@ -2548,14 +2544,24 @@ namespace MediaBrowser.Controller.Entities
StringComparison.OrdinalIgnoreCase);
}
- public IReadOnlyList<BaseItem> GetThemeSongs()
+ public IReadOnlyList<BaseItem> GetThemeSongs(User user = null)
+ {
+ return GetThemeSongs(user, Array.Empty<(ItemSortBy, SortOrder)>());
+ }
+
+ public IReadOnlyList<BaseItem> GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
+ {
+ return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray();
+ }
+
+ public IReadOnlyList<BaseItem> GetThemeVideos(User user = null)
{
- return GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong).ToArray();
+ return GetThemeVideos(user, Array.Empty<(ItemSortBy, SortOrder)>());
}
- public IReadOnlyList<BaseItem> GetThemeVideos()
+ public IReadOnlyList<BaseItem> GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
{
- return GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo).ToArray();
+ return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
}
/// <summary>
diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs
index 676a47c88..4ead477f8 100644
--- a/MediaBrowser.Controller/Entities/CollectionFolder.cs
+++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs
@@ -316,11 +316,12 @@ namespace MediaBrowser.Controller.Entities
/// <param name="progress">The progress.</param>
/// <param name="recursive">if set to <c>true</c> [recursive].</param>
/// <param name="refreshChildMetadata">if set to <c>true</c> [refresh child metadata].</param>
+ /// <param name="allowRemoveRoot">remove item even this folder is root.</param>
/// <param name="refreshOptions">The refresh options.</param>
/// <param name="directoryService">The directory service.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
+ protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
diff --git a/MediaBrowser.Controller/Entities/Extensions.cs b/MediaBrowser.Controller/Entities/Extensions.cs
index 3005bee0a..c56603a3e 100644
--- a/MediaBrowser.Controller/Entities/Extensions.cs
+++ b/MediaBrowser.Controller/Entities/Extensions.cs
@@ -30,15 +30,11 @@ namespace MediaBrowser.Controller.Entities
if (item.RemoteTrailers.Count == 0)
{
- item.RemoteTrailers = new[] { mediaUrl };
+ item.RemoteTrailers = [mediaUrl];
}
else
{
- var oldIds = item.RemoteTrailers;
- var newIds = new MediaUrl[oldIds.Count + 1];
- oldIds.CopyTo(newIds);
- newIds[oldIds.Count] = mediaUrl;
- item.RemoteTrailers = newIds;
+ item.RemoteTrailers = [..item.RemoteTrailers, mediaUrl];
}
}
}
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index a2957cdca..b2e5d7263 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -6,6 +6,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Security;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@@ -269,11 +270,12 @@ namespace MediaBrowser.Controller.Entities
/// <param name="progress">The progress.</param>
/// <param name="metadataRefreshOptions">The metadata refresh options.</param>
/// <param name="recursive">if set to <c>true</c> [recursive].</param>
+ /// <param name="allowRemoveRoot">remove item even this folder is root.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- public Task ValidateChildren(IProgress<double> progress, MetadataRefreshOptions metadataRefreshOptions, bool recursive = true, CancellationToken cancellationToken = default)
+ public Task ValidateChildren(IProgress<double> progress, MetadataRefreshOptions metadataRefreshOptions, bool recursive = true, bool allowRemoveRoot = false, CancellationToken cancellationToken = default)
{
- return ValidateChildrenInternal(progress, recursive, true, metadataRefreshOptions, metadataRefreshOptions.DirectoryService, cancellationToken);
+ return ValidateChildrenInternal(progress, recursive, true, allowRemoveRoot, metadataRefreshOptions, metadataRefreshOptions.DirectoryService, cancellationToken);
}
private Dictionary<Guid, BaseItem> GetActualChildrenDictionary()
@@ -307,11 +309,12 @@ namespace MediaBrowser.Controller.Entities
/// <param name="progress">The progress.</param>
/// <param name="recursive">if set to <c>true</c> [recursive].</param>
/// <param name="refreshChildMetadata">if set to <c>true</c> [refresh child metadata].</param>
+ /// <param name="allowRemoveRoot">remove item even this folder is root.</param>
/// <param name="refreshOptions">The refresh options.</param>
/// <param name="directoryService">The directory service.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- protected virtual async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
+ protected virtual async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
{
if (recursive)
{
@@ -320,7 +323,7 @@ namespace MediaBrowser.Controller.Entities
try
{
- await ValidateChildrenInternal2(progress, recursive, refreshChildMetadata, refreshOptions, directoryService, cancellationToken).ConfigureAwait(false);
+ await ValidateChildrenInternal2(progress, recursive, refreshChildMetadata, allowRemoveRoot, refreshOptions, directoryService, cancellationToken).ConfigureAwait(false);
}
finally
{
@@ -331,8 +334,13 @@ namespace MediaBrowser.Controller.Entities
}
}
- private static bool IsLibraryFolderAccessible(IDirectoryService directoryService, BaseItem item)
+ private static bool IsLibraryFolderAccessible(IDirectoryService directoryService, BaseItem item, bool checkCollection)
{
+ if (!checkCollection && (item is BoxSet || string.Equals(item.FileNameWithoutExtension, "collections", StringComparison.OrdinalIgnoreCase)))
+ {
+ return true;
+ }
+
// For top parents i.e. Library folders, skip the validation if it's empty or inaccessible
if (item.IsTopParent && !directoryService.IsAccessible(item.ContainingFolderPath))
{
@@ -343,9 +351,9 @@ namespace MediaBrowser.Controller.Entities
return true;
}
- private async Task ValidateChildrenInternal2(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
+ private async Task ValidateChildrenInternal2(IProgress<double> progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
{
- if (!IsLibraryFolderAccessible(directoryService, this))
+ if (!IsLibraryFolderAccessible(directoryService, this, allowRemoveRoot))
{
return;
}
@@ -357,15 +365,23 @@ namespace MediaBrowser.Controller.Entities
if (IsFileProtocol)
{
- IEnumerable<BaseItem> nonCachedChildren;
+ IEnumerable<BaseItem> nonCachedChildren = [];
try
{
nonCachedChildren = GetNonCachedChildren(directoryService);
}
+ catch (IOException ex)
+ {
+ Logger.LogError(ex, "Error retrieving children from file system");
+ }
+ catch (SecurityException ex)
+ {
+ Logger.LogError(ex, "Error retrieving children from file system");
+ }
catch (Exception ex)
{
- Logger.LogError(ex, "Error retrieving children folder");
+ Logger.LogError(ex, "Error retrieving children");
return;
}
@@ -386,7 +402,7 @@ namespace MediaBrowser.Controller.Entities
foreach (var child in nonCachedChildren)
{
- if (!IsLibraryFolderAccessible(directoryService, child))
+ if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot))
{
continue;
}
@@ -414,12 +430,12 @@ namespace MediaBrowser.Controller.Entities
validChildren.Add(child);
}
+ // That's all the new and changed ones - now see if any have been removed and need cleanup
+ var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
+ var shouldRemove = !IsRoot || allowRemoveRoot;
// If it's an AggregateFolder, don't remove
- if (!IsRoot && currentChildren.Count != validChildren.Count)
+ if (shouldRemove && itemsRemoved.Count > 0)
{
- // That's all the new and changed ones - now see if there are any that are missing
- var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
-
foreach (var item in itemsRemoved)
{
if (item.IsFileProtocol)
@@ -460,15 +476,7 @@ namespace MediaBrowser.Controller.Entities
progress.Report(percent);
- // TODO: this is sometimes being called after the refresh has completed.
- try
- {
- ProviderManager.OnRefreshProgress(folder, percent);
- }
- catch (InvalidOperationException e)
- {
- Logger.LogError(e, "Error refreshing folder");
- }
+ ProviderManager.OnRefreshProgress(folder, percent);
});
if (validChildrenNeedGeneration)
@@ -500,15 +508,7 @@ namespace MediaBrowser.Controller.Entities
if (recursive)
{
- // TODO: this is sometimes being called after the refresh has completed.
- try
- {
- ProviderManager.OnRefreshProgress(folder, percent);
- }
- catch (InvalidOperationException e)
- {
- Logger.LogError(e, "Error refreshing folder");
- }
+ ProviderManager.OnRefreshProgress(folder, percent);
}
});
@@ -578,7 +578,7 @@ namespace MediaBrowser.Controller.Entities
private Task ValidateSubFolders(IList<Folder> children, IDirectoryService directoryService, IProgress<double> progress, CancellationToken cancellationToken)
{
return RunTasks(
- (folder, innerProgress) => folder.ValidateChildrenInternal(innerProgress, true, false, null, directoryService, cancellationToken),
+ (folder, innerProgress) => folder.ValidateChildrenInternal(innerProgress, true, false, false, null, directoryService, cancellationToken),
children,
progress,
cancellationToken);
@@ -603,7 +603,7 @@ namespace MediaBrowser.Controller.Entities
}
var fanoutConcurrency = ConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
- var parallelism = fanoutConcurrency > 0 ? fanoutConcurrency : 2 * Environment.ProcessorCount;
+ var parallelism = fanoutConcurrency > 0 ? fanoutConcurrency : Environment.ProcessorCount;
var actionBlock = new ActionBlock<int>(
async i =>
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 555dd050c..1461a3680 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -51,6 +51,7 @@ namespace MediaBrowser.Controller.Entities
TrailerTypes = Array.Empty<TrailerType>();
VideoTypes = Array.Empty<VideoType>();
Years = Array.Empty<int>();
+ SkipDeserialization = false;
}
public InternalItemsQuery(User? user)
@@ -358,6 +359,8 @@ namespace MediaBrowser.Controller.Entities
public string? SeriesTimerId { get; set; }
+ public bool SkipDeserialization { get; set; }
+
public void SetUser(User user)
{
MaxParentalRating = user.MaxParentalAgeRating;
diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs
index 81f6248fa..710b05e7f 100644
--- a/MediaBrowser.Controller/Entities/Movies/Movie.cs
+++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs
@@ -45,9 +45,6 @@ namespace MediaBrowser.Controller.Entities.Movies
set => TmdbCollectionName = value;
}
- [JsonIgnore]
- public override bool StopRefreshIfLocalMetadataFound => false;
-
public override double GetDefaultPrimaryImageAspectRatio()
{
// hack for tv plugins
@@ -124,23 +121,5 @@ namespace MediaBrowser.Controller.Entities.Movies
return hasChanges;
}
-
- /// <inheritdoc />
- public override List<ExternalUrl> GetRelatedUrls()
- {
- var list = base.GetRelatedUrls();
-
- var imdbId = this.GetProviderId(MetadataProvider.Imdb);
- if (!string.IsNullOrEmpty(imdbId))
- {
- list.Add(new ExternalUrl
- {
- Name = "Trakt",
- Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/movies/{0}", imdbId)
- });
- }
-
- return list;
- }
}
}
diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs
index 37e241414..5c54f014c 100644
--- a/MediaBrowser.Controller/Entities/TV/Episode.cs
+++ b/MediaBrowser.Controller/Entities/TV/Episode.cs
@@ -344,22 +344,5 @@ namespace MediaBrowser.Controller.Entities.TV
return hasChanges;
}
-
- public override List<ExternalUrl> GetRelatedUrls()
- {
- var list = base.GetRelatedUrls();
-
- var imdbId = this.GetProviderId(MetadataProvider.Imdb);
- if (!string.IsNullOrEmpty(imdbId))
- {
- list.Add(new ExternalUrl
- {
- Name = "Trakt",
- Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/episodes/{0}", imdbId)
- });
- }
-
- return list;
- }
}
}
diff --git a/MediaBrowser.Controller/Entities/TV/Season.cs b/MediaBrowser.Controller/Entities/TV/Season.cs
index c29cefc15..083f12746 100644
--- a/MediaBrowser.Controller/Entities/TV/Season.cs
+++ b/MediaBrowser.Controller/Entities/TV/Season.cs
@@ -159,7 +159,7 @@ namespace MediaBrowser.Controller.Entities.TV
Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
- var items = GetEpisodes(user, query.DtoOptions).Where(filter);
+ var items = GetEpisodes(user, query.DtoOptions, true).Where(filter);
return PostFilterAndSort(items, query, false);
}
@@ -169,30 +169,31 @@ namespace MediaBrowser.Controller.Entities.TV
/// </summary>
/// <param name="user">The user.</param>
/// <param name="options">The options to use.</param>
+ /// <param name="shouldIncludeMissingEpisodes">If missing episodes should be included.</param>
/// <returns>Set of episodes.</returns>
- public List<BaseItem> GetEpisodes(User user, DtoOptions options)
+ public List<BaseItem> GetEpisodes(User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
- return GetEpisodes(Series, user, options);
+ return GetEpisodes(Series, user, options, shouldIncludeMissingEpisodes);
}
- public List<BaseItem> GetEpisodes(Series series, User user, DtoOptions options)
+ public List<BaseItem> GetEpisodes(Series series, User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
- return GetEpisodes(series, user, null, options);
+ return GetEpisodes(series, user, null, options, shouldIncludeMissingEpisodes);
}
- public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options)
+ public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
- return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options);
+ return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
}
public List<BaseItem> GetEpisodes()
{
- return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true));
+ return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
}
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
{
- return GetEpisodes(user, new DtoOptions(true));
+ return GetEpisodes(user, new DtoOptions(true), true);
}
protected override bool GetBlockUnratedValue(User user)
diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs
index a49c1609d..a324f79ef 100644
--- a/MediaBrowser.Controller/Entities/TV/Series.cs
+++ b/MediaBrowser.Controller/Entities/TV/Series.cs
@@ -28,7 +28,6 @@ namespace MediaBrowser.Controller.Entities.TV
public Series()
{
AirDays = Array.Empty<DayOfWeek>();
- SeasonNames = new Dictionary<int, string>();
}
public DayOfWeek[] AirDays { get; set; }
@@ -36,9 +35,6 @@ namespace MediaBrowser.Controller.Entities.TV
public string AirTime { get; set; }
[JsonIgnore]
- public Dictionary<int, string> SeasonNames { get; set; }
-
- [JsonIgnore]
public override bool SupportsAddingToPlaylist => true;
[JsonIgnore]
@@ -73,9 +69,6 @@ namespace MediaBrowser.Controller.Entities.TV
/// <value>The status.</value>
public SeriesStatus? Status { get; set; }
- [JsonIgnore]
- public override bool StopRefreshIfLocalMetadataFound => false;
-
public override double GetDefaultPrimaryImageAspectRatio()
{
double value = 2;
@@ -257,7 +250,7 @@ namespace MediaBrowser.Controller.Entities.TV
return LibraryManager.GetItemsResult(query);
}
- public IEnumerable<BaseItem> GetEpisodes(User user, DtoOptions options)
+ public IEnumerable<BaseItem> GetEpisodes(User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
var seriesKey = GetUniqueSeriesKey(this);
@@ -267,10 +260,10 @@ namespace MediaBrowser.Controller.Entities.TV
SeriesPresentationUniqueKey = seriesKey,
IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Season },
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
- DtoOptions = options
+ DtoOptions = options,
};
- if (user is null || !user.DisplayMissingEpisodes)
+ if (!shouldIncludeMissingEpisodes)
{
query.IsMissing = false;
}
@@ -280,7 +273,7 @@ namespace MediaBrowser.Controller.Entities.TV
var allSeriesEpisodes = allItems.OfType<Episode>().ToList();
var allEpisodes = allItems.OfType<Season>()
- .SelectMany(i => i.GetEpisodes(this, user, allSeriesEpisodes, options))
+ .SelectMany(i => i.GetEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes))
.Reverse();
// Specials could appear twice based on above - once in season 0, once in the aired season
@@ -292,8 +285,7 @@ namespace MediaBrowser.Controller.Entities.TV
public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
{
- // Refresh bottom up, children first, then the boxset
- // By then hopefully the movies within will have Tmdb collection values
+ // Refresh bottom up, seasons and episodes first, then the series
var items = GetRecursiveChildren();
var totalItems = items.Count;
@@ -356,7 +348,7 @@ namespace MediaBrowser.Controller.Entities.TV
await ProviderManager.RefreshSingleItem(this, refreshOptions, cancellationToken).ConfigureAwait(false);
}
- public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options)
+ public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
var queryFromSeries = ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons;
@@ -373,24 +365,22 @@ namespace MediaBrowser.Controller.Entities.TV
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
DtoOptions = options
};
- if (user is not null)
+
+ if (!shouldIncludeMissingEpisodes)
{
- if (!user.DisplayMissingEpisodes)
- {
- query.IsMissing = false;
- }
+ query.IsMissing = false;
}
var allItems = LibraryManager.GetItemList(query);
- return GetSeasonEpisodes(parentSeason, user, allItems, options);
+ return GetSeasonEpisodes(parentSeason, user, allItems, options, shouldIncludeMissingEpisodes);
}
- public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, IEnumerable<BaseItem> allSeriesEpisodes, DtoOptions options)
+ public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, IEnumerable<BaseItem> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
{
if (allSeriesEpisodes is null)
{
- return GetSeasonEpisodes(parentSeason, user, options);
+ return GetSeasonEpisodes(parentSeason, user, options, shouldIncludeMissingEpisodes);
}
var episodes = FilterEpisodesBySeason(allSeriesEpisodes, parentSeason, ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons);
@@ -499,22 +489,5 @@ namespace MediaBrowser.Controller.Entities.TV
return hasChanges;
}
-
- public override List<ExternalUrl> GetRelatedUrls()
- {
- var list = base.GetRelatedUrls();
-
- var imdbId = this.GetProviderId(MetadataProvider.Imdb);
- if (!string.IsNullOrEmpty(imdbId))
- {
- list.Add(new ExternalUrl
- {
- Name = "Trakt",
- Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/shows/{0}", imdbId)
- });
- }
-
- return list;
- }
}
}
diff --git a/MediaBrowser.Controller/Entities/TagExtensions.cs b/MediaBrowser.Controller/Entities/TagExtensions.cs
index ec3eb0f70..c1e4d1db2 100644
--- a/MediaBrowser.Controller/Entities/TagExtensions.cs
+++ b/MediaBrowser.Controller/Entities/TagExtensions.cs
@@ -21,11 +21,11 @@ namespace MediaBrowser.Controller.Entities
{
if (current.Length == 0)
{
- item.Tags = new[] { name };
+ item.Tags = [name];
}
else
{
- item.Tags = current.Concat(new[] { name }).ToArray();
+ item.Tags = [..current, name];
}
}
}
diff --git a/MediaBrowser.Controller/Entities/Trailer.cs b/MediaBrowser.Controller/Entities/Trailer.cs
index 1c558d419..939709215 100644
--- a/MediaBrowser.Controller/Entities/Trailer.cs
+++ b/MediaBrowser.Controller/Entities/Trailer.cs
@@ -23,9 +23,6 @@ namespace MediaBrowser.Controller.Entities
TrailerTypes = Array.Empty<TrailerType>();
}
- [JsonIgnore]
- public override bool StopRefreshIfLocalMetadataFound => false;
-
public TrailerType[] TrailerTypes { get; set; }
public override double GetDefaultPrimaryImageAspectRatio()
@@ -83,22 +80,5 @@ namespace MediaBrowser.Controller.Entities
return hasChanges;
}
-
- public override List<ExternalUrl> GetRelatedUrls()
- {
- var list = base.GetRelatedUrls();
-
- var imdbId = this.GetProviderId(MetadataProvider.Imdb);
- if (!string.IsNullOrEmpty(imdbId))
- {
- list.Add(new ExternalUrl
- {
- Name = "Trakt",
- Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/movies/{0}", imdbId)
- });
- }
-
- return list;
- }
}
}
diff --git a/MediaBrowser.Controller/Entities/UserRootFolder.cs b/MediaBrowser.Controller/Entities/UserRootFolder.cs
index 69743b926..fc8a29763 100644
--- a/MediaBrowser.Controller/Entities/UserRootFolder.cs
+++ b/MediaBrowser.Controller/Entities/UserRootFolder.cs
@@ -117,11 +117,11 @@ namespace MediaBrowser.Controller.Entities
return base.GetNonCachedChildren(directoryService);
}
- protected override async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
+ protected override async Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
{
ClearCache();
- await base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, refreshOptions, directoryService, cancellationToken)
+ await base.ValidateChildrenInternal(progress, recursive, refreshChildMetadata, allowRemoveRoot, refreshOptions, directoryService, cancellationToken)
.ConfigureAwait(false);
ClearCache();
diff --git a/MediaBrowser.Controller/Entities/UserView.cs b/MediaBrowser.Controller/Entities/UserView.cs
index c93488a85..e4fb340f7 100644
--- a/MediaBrowser.Controller/Entities/UserView.cs
+++ b/MediaBrowser.Controller/Entities/UserView.cs
@@ -6,10 +6,12 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
+using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.TV;
using MediaBrowser.Model.Querying;
@@ -180,7 +182,7 @@ namespace MediaBrowser.Controller.Entities
return _originalFolderViewTypes.Contains(viewType);
}
- protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, Providers.MetadataRefreshOptions refreshOptions, Providers.IDirectoryService directoryService, System.Threading.CancellationToken cancellationToken)
+ protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index 4af000557..3a1d0c070 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -744,7 +744,7 @@ namespace MediaBrowser.Controller.Entities
{
var filterValue = query.HasThemeSong.Value;
- var themeCount = item.GetThemeSongs().Count;
+ var themeCount = item.GetThemeSongs(user).Count;
var ok = filterValue ? themeCount > 0 : themeCount == 0;
if (!ok)
@@ -757,7 +757,7 @@ namespace MediaBrowser.Controller.Entities
{
var filterValue = query.HasThemeVideo.Value;
- var themeCount = item.GetThemeVideos().Count;
+ var themeCount = item.GetThemeVideos(user).Count;
var ok = filterValue ? themeCount > 0 : themeCount == 0;
if (!ok)
diff --git a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs
index 6c58064ce..7dfda73bf 100644
--- a/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs
+++ b/MediaBrowser.Controller/Extensions/ConfigurationExtensions.cs
@@ -65,6 +65,11 @@ namespace MediaBrowser.Controller.Extensions
public const string SqliteCacheSizeKey = "sqlite:cacheSize";
/// <summary>
+ /// Disable second level cache of sqlite.
+ /// </summary>
+ public const string SqliteDisableSecondLevelCacheKey = "sqlite:disableSecondLevelCache";
+
+ /// <summary>
/// Gets a value indicating whether the application should host static web content from the <see cref="IConfiguration"/>.
/// </summary>
/// <param name="configuration">The configuration to retrieve the value from.</param>
@@ -128,5 +133,15 @@ namespace MediaBrowser.Controller.Extensions
/// <returns>The sqlite cache size.</returns>
public static int? GetSqliteCacheSize(this IConfiguration configuration)
=> configuration.GetValue<int?>(SqliteCacheSizeKey);
+
+ /// <summary>
+ /// Gets whether second level cache disabled from the <see cref="IConfiguration" />.
+ /// </summary>
+ /// <param name="configuration">The configuration to read the setting from.</param>
+ /// <returns>Whether second level cache disabled.</returns>
+ public static bool GetSqliteSecondLevelCacheDisabled(this IConfiguration configuration)
+ {
+ return configuration.GetValue<bool>(SqliteDisableSecondLevelCacheKey);
+ }
}
}
diff --git a/MediaBrowser.Controller/IO/FileSystemHelper.cs b/MediaBrowser.Controller/IO/FileSystemHelper.cs
new file mode 100644
index 000000000..1a33c3aa8
--- /dev/null
+++ b/MediaBrowser.Controller/IO/FileSystemHelper.cs
@@ -0,0 +1,64 @@
+using System;
+using System.IO;
+using System.Linq;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.IO;
+
+/// <summary>
+/// Helper methods for file system management.
+/// </summary>
+public static class FileSystemHelper
+{
+ /// <summary>
+ /// Deletes the file.
+ /// </summary>
+ /// <param name="fileSystem">The fileSystem.</param>
+ /// <param name="path">The path.</param>
+ /// <param name="logger">The logger.</param>
+ public static void DeleteFile(IFileSystem fileSystem, string path, ILogger logger)
+ {
+ try
+ {
+ fileSystem.DeleteFile(path);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger.LogError(ex, "Error deleting file {Path}", path);
+ }
+ catch (IOException ex)
+ {
+ logger.LogError(ex, "Error deleting file {Path}", path);
+ }
+ }
+
+ /// <summary>
+ /// Recursively delete empty folders.
+ /// </summary>
+ /// <param name="fileSystem">The fileSystem.</param>
+ /// <param name="path">The path.</param>
+ /// <param name="logger">The logger.</param>
+ public static void DeleteEmptyFolders(IFileSystem fileSystem, string path, ILogger logger)
+ {
+ foreach (var directory in fileSystem.GetDirectoryPaths(path))
+ {
+ DeleteEmptyFolders(fileSystem, directory, logger);
+ if (!fileSystem.GetFileSystemEntryPaths(directory).Any())
+ {
+ try
+ {
+ Directory.Delete(directory, false);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger.LogError(ex, "Error deleting directory {Path}", directory);
+ }
+ catch (IOException ex)
+ {
+ logger.LogError(ex, "Error deleting directory {Path}", directory);
+ }
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 6532f7a34..b802b7e6e 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -1,5 +1,3 @@
-#nullable disable
-
#pragma warning disable CA1002, CS1591
using System;
@@ -33,17 +31,17 @@ namespace MediaBrowser.Controller.Library
/// <summary>
/// Occurs when [item added].
/// </summary>
- event EventHandler<ItemChangeEventArgs> ItemAdded;
+ event EventHandler<ItemChangeEventArgs>? ItemAdded;
/// <summary>
/// Occurs when [item updated].
/// </summary>
- event EventHandler<ItemChangeEventArgs> ItemUpdated;
+ event EventHandler<ItemChangeEventArgs>? ItemUpdated;
/// <summary>
/// Occurs when [item removed].
/// </summary>
- event EventHandler<ItemChangeEventArgs> ItemRemoved;
+ event EventHandler<ItemChangeEventArgs>? ItemRemoved;
/// <summary>
/// Gets the root folder.
@@ -60,10 +58,10 @@ namespace MediaBrowser.Controller.Library
/// <param name="parent">The parent.</param>
/// <param name="directoryService">An instance of <see cref="IDirectoryService"/>.</param>
/// <returns>BaseItem.</returns>
- BaseItem ResolvePath(
+ BaseItem? ResolvePath(
FileSystemMetadata fileInfo,
- Folder parent = null,
- IDirectoryService directoryService = null);
+ Folder? parent = null,
+ IDirectoryService? directoryService = null);
/// <summary>
/// Resolves a set of files into a list of BaseItem.
@@ -86,7 +84,7 @@ namespace MediaBrowser.Controller.Library
/// </summary>
/// <param name="name">The name of the person.</param>
/// <returns>Task{Person}.</returns>
- Person GetPerson(string name);
+ Person? GetPerson(string name);
/// <summary>
/// Finds the by path.
@@ -94,7 +92,7 @@ namespace MediaBrowser.Controller.Library
/// <param name="path">The path.</param>
/// <param name="isFolder"><c>true</c> is the path is a directory; otherwise <c>false</c>.</param>
/// <returns>BaseItem.</returns>
- BaseItem FindByPath(string path, bool? isFolder);
+ BaseItem? FindByPath(string path, bool? isFolder);
/// <summary>
/// Gets the artist.
@@ -151,6 +149,14 @@ namespace MediaBrowser.Controller.Library
/// <returns>Task.</returns>
Task ValidateMediaLibrary(IProgress<double> progress, CancellationToken cancellationToken);
+ /// <summary>
+ /// Reloads the root media folder.
+ /// </summary>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="removeRoot">Is remove the library itself allowed.</param>
+ /// <returns>Task.</returns>
+ Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false);
+
Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false);
/// <summary>
@@ -166,7 +172,8 @@ namespace MediaBrowser.Controller.Library
/// </summary>
/// <param name="id">The id.</param>
/// <returns>BaseItem.</returns>
- BaseItem GetItemById(Guid id);
+ /// <exception cref="ArgumentNullException"><paramref name="id"/> is <c>null</c>.</exception>
+ BaseItem? GetItemById(Guid id);
/// <summary>
/// Gets the item by id, as T.
@@ -174,7 +181,27 @@ namespace MediaBrowser.Controller.Library
/// <param name="id">The item id.</param>
/// <typeparam name="T">The type of item.</typeparam>
/// <returns>The item.</returns>
- T GetItemById<T>(Guid id)
+ T? GetItemById<T>(Guid id)
+ where T : BaseItem;
+
+ /// <summary>
+ /// Gets the item by id, as T, and validates user access.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="userId">The user id to validate against.</param>
+ /// <typeparam name="T">The type of item.</typeparam>
+ /// <returns>The item if found.</returns>
+ public T? GetItemById<T>(Guid id, Guid userId)
+ where T : BaseItem;
+
+ /// <summary>
+ /// Gets the item by id, as T, and validates user access.
+ /// </summary>
+ /// <param name="id">The item id.</param>
+ /// <param name="user">The user to validate against.</param>
+ /// <typeparam name="T">The type of item.</typeparam>
+ /// <returns>The item if found.</returns>
+ public T? GetItemById<T>(Guid id, User? user)
where T : BaseItem;
/// <summary>
@@ -208,9 +235,9 @@ namespace MediaBrowser.Controller.Library
/// <param name="sortBy">The sort by.</param>
/// <param name="sortOrder">The sort order.</param>
/// <returns>IEnumerable{BaseItem}.</returns>
- IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder);
+ IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<ItemSortBy> sortBy, SortOrder sortOrder);
- IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy);
+ IEnumerable<BaseItem> Sort(IEnumerable<BaseItem> items, User? user, IEnumerable<(ItemSortBy OrderBy, SortOrder SortOrder)> orderBy);
/// <summary>
/// Gets the user root folder.
@@ -223,7 +250,7 @@ namespace MediaBrowser.Controller.Library
/// </summary>
/// <param name="item">Item to create.</param>
/// <param name="parent">Parent of new item.</param>
- void CreateItem(BaseItem item, BaseItem parent);
+ void CreateItem(BaseItem item, BaseItem? parent);
/// <summary>
/// Creates the items.
@@ -231,7 +258,7 @@ namespace MediaBrowser.Controller.Library
/// <param name="items">Items to create.</param>
/// <param name="parent">Parent of new items.</param>
/// <param name="cancellationToken">CancellationToken to use for operation.</param>
- void CreateItems(IReadOnlyList<BaseItem> items, BaseItem parent, CancellationToken cancellationToken);
+ void CreateItems(IReadOnlyList<BaseItem> items, BaseItem? parent, CancellationToken cancellationToken);
/// <summary>
/// Updates the item.
@@ -509,7 +536,7 @@ namespace MediaBrowser.Controller.Library
/// <returns>QueryResult&lt;BaseItem&gt;.</returns>
QueryResult<BaseItem> QueryItems(InternalItemsQuery query);
- string GetPathAfterNetworkSubstitution(string path, BaseItem ownerItem = null);
+ string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem = null);
/// <summary>
/// Converts the image to local.
diff --git a/MediaBrowser.Controller/Library/IMediaSourceManager.cs b/MediaBrowser.Controller/Library/IMediaSourceManager.cs
index bace703ad..44a1a85e3 100644
--- a/MediaBrowser.Controller/Library/IMediaSourceManager.cs
+++ b/MediaBrowser.Controller/Library/IMediaSourceManager.cs
@@ -138,7 +138,7 @@ namespace MediaBrowser.Controller.Library
MediaProtocol GetPathProtocol(string path);
- void SetDefaultAudioAndSubtitleStreamIndexes(BaseItem item, MediaSourceInfo source, User user);
+ void SetDefaultAudioAndSubtitleStreamIndices(BaseItem item, MediaSourceInfo source, User user);
Task AddMediaInfoWithProbe(MediaSourceInfo mediaSource, bool isAudio, string cacheKey, bool addProbeDelay, bool isLiveStream, CancellationToken cancellationToken);
}
diff --git a/MediaBrowser.Controller/Library/ItemResolveArgs.cs b/MediaBrowser.Controller/Library/ItemResolveArgs.cs
index 6202f92f5..b558ef73d 100644
--- a/MediaBrowser.Controller/Library/ItemResolveArgs.cs
+++ b/MediaBrowser.Controller/Library/ItemResolveArgs.cs
@@ -116,8 +116,8 @@ namespace MediaBrowser.Controller.Library
{
get
{
- var paths = string.IsNullOrEmpty(Path) ? Array.Empty<string>() : new[] { Path };
- return AdditionalLocations is null ? paths : paths.Concat(AdditionalLocations).ToArray();
+ var paths = string.IsNullOrEmpty(Path) ? Array.Empty<string>() : [Path];
+ return AdditionalLocations is null ? paths : [..paths, ..AdditionalLocations];
}
}
diff --git a/MediaBrowser.Controller/LiveTv/LiveTvConflictException.cs b/MediaBrowser.Controller/LiveTv/LiveTvConflictException.cs
index 881c42c73..3a062a467 100644
--- a/MediaBrowser.Controller/LiveTv/LiveTvConflictException.cs
+++ b/MediaBrowser.Controller/LiveTv/LiveTvConflictException.cs
@@ -9,10 +9,6 @@ namespace MediaBrowser.Controller.LiveTv
/// </summary>
public class LiveTvConflictException : Exception
{
- public LiveTvConflictException()
- {
- }
-
public LiveTvConflictException(string message)
: base(message)
{
diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs
index 05540d490..2ac6f9963 100644
--- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs
+++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs
@@ -254,25 +254,5 @@ namespace MediaBrowser.Controller.LiveTv
return name;
}
-
- public override List<ExternalUrl> GetRelatedUrls()
- {
- var list = base.GetRelatedUrls();
-
- var imdbId = this.GetProviderId(MetadataProvider.Imdb);
- if (!string.IsNullOrEmpty(imdbId))
- {
- if (IsMovie)
- {
- list.Add(new ExternalUrl
- {
- Name = "Trakt",
- Url = string.Format(CultureInfo.InvariantCulture, "https://trakt.tv/movies/{0}", imdbId)
- });
- }
- }
-
- return list;
- }
}
}
diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
index f237993fd..1ef2eb343 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.9.0</VersionPrefix>
+ <VersionPrefix>10.10.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
index 29dd190ab..03ec6c658 100644
--- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
+++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
@@ -191,6 +191,8 @@ namespace MediaBrowser.Controller.MediaEncoding
public Dictionary<string, string> StreamOptions { get; set; }
+ public bool EnableAudioVbrEncoding { get; set; }
+
public string GetOption(string qualifier, string name)
{
var value = GetOption(qualifier + "-" + name);
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 5143d5f74..eb80bab2d 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -9,6 +9,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
@@ -55,6 +56,7 @@ namespace MediaBrowser.Controller.MediaEncoding
private readonly Version _minKerneli915Hang = new Version(5, 18);
private readonly Version _maxKerneli915Hang = new Version(6, 1, 3);
private readonly Version _minFixedKernel60i915Hang = new Version(6, 0, 18);
+ private readonly Version _minKernelVersionAmdVkFmtModifier = new Version(5, 15);
private readonly Version _minFFmpegImplictHwaccel = new Version(6, 0);
private readonly Version _minFFmpegHwaUnsafeOutput = new Version(6, 0);
@@ -108,7 +110,6 @@ namespace MediaBrowser.Controller.MediaEncoding
{ "wmav2", 2 },
{ "libmp3lame", 2 },
{ "libfdk_aac", 6 },
- { "aac_at", 6 },
{ "ac3", 6 },
{ "eac3", 6 },
{ "dca", 6 },
@@ -120,7 +121,8 @@ namespace MediaBrowser.Controller.MediaEncoding
private static readonly Dictionary<string, string> _mjpegCodecMap = new(StringComparer.OrdinalIgnoreCase)
{
{ "vaapi", _defaultMjpegEncoder + "_vaapi" },
- { "qsv", _defaultMjpegEncoder + "_qsv" }
+ { "qsv", _defaultMjpegEncoder + "_qsv" },
+ { "videotoolbox", _defaultMjpegEncoder + "_videotoolbox" }
};
public static readonly string[] LosslessAudioCodecs = new string[]
@@ -285,6 +287,21 @@ namespace MediaBrowser.Controller.MediaEncoding
// Let transpose_vt optional for the time being.
}
+ private bool IsSwTonemapAvailable(EncodingJobInfo state, EncodingOptions options)
+ {
+ if (state.VideoStream is null
+ || !options.EnableTonemapping
+ || GetVideoColorBitDepth(state) != 10
+ || !_mediaEncoder.SupportsFilter("tonemapx")
+ || !(string.Equals(state.VideoStream?.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase) || string.Equals(state.VideoStream?.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase)))
+ {
+ return false;
+ }
+
+ return state.VideoStream.VideoRange == VideoRange.HDR
+ && state.VideoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG or VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithHLG;
+ }
+
private bool IsHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options)
{
if (state.VideoStream is null
@@ -691,16 +708,6 @@ namespace MediaBrowser.Controller.MediaEncoding
return -1;
}
- public string GetInputPathArgument(EncodingJobInfo state)
- {
- return state.MediaSource.VideoType switch
- {
- VideoType.Dvd => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistVobFiles(state.MediaPath, null).ToList(), state.MediaSource),
- VideoType.BluRay => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistM2tsFiles(state.MediaPath).ToList(), state.MediaSource),
- _ => _mediaEncoder.GetInputArgument(state.MediaPath, state.MediaSource)
- };
- }
-
/// <summary>
/// Gets the audio encoder.
/// </summary>
@@ -762,6 +769,15 @@ namespace MediaBrowser.Controller.MediaEncoding
return "dca";
}
+ if (string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase))
+ {
+ // The ffmpeg upstream breaks the AudioToolbox ALAC encoder in version 6.1 but fixes it in version 7.0.
+ // Since ALAC is lossless in quality and the AudioToolbox encoder is not faster,
+ // its only benefit is a smaller file size.
+ // To prevent problems, use the ffmpeg native encoder instead.
+ return "alac";
+ }
+
return codec.ToLowerInvariant();
}
@@ -1007,7 +1023,8 @@ namespace MediaBrowser.Controller.MediaEncoding
Environment.SetEnvironmentVariable("AMD_DEBUG", "noefc");
if (IsVulkanFullSupported()
- && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop)
+ && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop
+ && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier)
{
args.Append(GetDrmDeviceArgs(options.VaapiDevice, DrmAlias));
args.Append(GetVaapiDeviceArgs(null, null, null, DrmAlias, VaapiAlias));
@@ -1194,15 +1211,20 @@ namespace MediaBrowser.Controller.MediaEncoding
if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
{
- var tmpConcatPath = Path.Join(_configurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat");
- _mediaEncoder.GenerateConcatConfig(state.MediaSource, tmpConcatPath);
- arg.Append(" -f concat -safe 0 -i ")
- .Append(tmpConcatPath);
+ var concatFilePath = Path.Join(_configurationManager.CommonApplicationPaths.CachePath, "concat", state.MediaSource.Id + ".concat");
+ if (!File.Exists(concatFilePath))
+ {
+ _mediaEncoder.GenerateConcatConfig(state.MediaSource, concatFilePath);
+ }
+
+ arg.Append(" -f concat -safe 0 -i \"")
+ .Append(concatFilePath)
+ .Append("\" ");
}
else
{
arg.Append(" -i ")
- .Append(GetInputPathArgument(state));
+ .Append(_mediaEncoder.GetInputPathArgument(state));
}
// sub2video for external graphical subtitles
@@ -1214,8 +1236,8 @@ namespace MediaBrowser.Controller.MediaEncoding
var subtitlePath = state.SubtitleStream.Path;
var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan());
- if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase)
- || subtitleExtension.Equals(".sup", StringComparison.OrdinalIgnoreCase))
+ // dvdsub/vobsub graphical subtitles use .sub+.idx pairs
+ if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
{
var idxFile = Path.ChangeExtension(subtitlePath, ".idx");
if (File.Exists(idxFile))
@@ -1270,23 +1292,23 @@ namespace MediaBrowser.Controller.MediaEncoding
{
var codec = stream.Codec ?? string.Empty;
- return codec.IndexOf("264", StringComparison.OrdinalIgnoreCase) != -1
- || codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1;
+ return codec.Contains("264", StringComparison.OrdinalIgnoreCase)
+ || codec.Contains("avc", StringComparison.OrdinalIgnoreCase);
}
public static bool IsH265(MediaStream stream)
{
var codec = stream.Codec ?? string.Empty;
- return codec.IndexOf("265", StringComparison.OrdinalIgnoreCase) != -1
- || codec.IndexOf("hevc", StringComparison.OrdinalIgnoreCase) != -1;
+ return codec.Contains("265", StringComparison.OrdinalIgnoreCase)
+ || codec.Contains("hevc", StringComparison.OrdinalIgnoreCase);
}
public static bool IsAAC(MediaStream stream)
{
var codec = stream.Codec ?? string.Empty;
- return codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1;
+ return codec.Contains("aac", StringComparison.OrdinalIgnoreCase);
}
public static string GetBitStreamArgs(MediaStream stream)
@@ -1340,7 +1362,7 @@ namespace MediaBrowser.Controller.MediaEncoding
return ".ts";
}
- public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec)
+ private string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec)
{
if (state.OutputVideoBitrate is null)
{
@@ -1409,7 +1431,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
// The `maxrate` and `bufsize` options can potentially lead to performance regression
// and even encoder hangs, especially when the value is very high.
- return FormattableString.Invariant($" -b:v {bitrate}");
+ return FormattableString.Invariant($" -b:v {bitrate} -qmin -1 -qmax -1");
}
return FormattableString.Invariant($" -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}");
@@ -2082,6 +2104,18 @@ namespace MediaBrowser.Controller.MediaEncoding
profile = "constrained_high";
}
+ if (string.Equals(videoEncoder, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase)
+ && profile.Contains("constrainedbaseline", StringComparison.OrdinalIgnoreCase))
+ {
+ profile = "constrained_baseline";
+ }
+
+ if (string.Equals(videoEncoder, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase)
+ && profile.Contains("constrainedhigh", StringComparison.OrdinalIgnoreCase))
+ {
+ profile = "constrained_high";
+ }
+
if (!string.IsNullOrEmpty(profile))
{
// Currently there's no profile option in av1_nvenc encoder
@@ -2315,7 +2349,11 @@ namespace MediaBrowser.Controller.MediaEncoding
if (request.VideoBitRate.HasValue
&& (!videoStream.BitRate.HasValue || videoStream.BitRate.Value > request.VideoBitRate.Value))
{
- return false;
+ // For LiveTV that has no bitrate, let's try copy if other conditions are met
+ if (string.IsNullOrWhiteSpace(request.LiveStreamId) || videoStream.BitRate.HasValue)
+ {
+ return false;
+ }
}
var maxBitDepth = state.GetRequestedVideoBitDepth(videoStream.Codec);
@@ -2575,8 +2613,9 @@ namespace MediaBrowser.Controller.MediaEncoding
return 128000 * (outputAudioChannels ?? audioStream.Channels ?? 2);
}
- public string GetAudioVbrModeParam(string encoder, int bitratePerChannel)
+ public string GetAudioVbrModeParam(string encoder, int bitrate, int channels)
{
+ var bitratePerChannel = bitrate / Math.Max(channels, 1);
if (string.Equals(encoder, "libfdk_aac", StringComparison.OrdinalIgnoreCase))
{
return " -vbr:a " + bitratePerChannel switch
@@ -2591,14 +2630,26 @@ namespace MediaBrowser.Controller.MediaEncoding
if (string.Equals(encoder, "libmp3lame", StringComparison.OrdinalIgnoreCase))
{
- return " -qscale:a " + bitratePerChannel switch
+ // lame's VBR is only good for a certain bitrate range
+ // For very low and very high bitrate, use abr mode
+ if (bitratePerChannel is < 122500 and > 48000)
{
- < 48000 => "8",
- < 64000 => "6",
- < 88000 => "4",
- < 112000 => "2",
- _ => "0"
- };
+ return " -qscale:a " + bitratePerChannel switch
+ {
+ < 64000 => "6",
+ < 88000 => "4",
+ < 112000 => "2",
+ _ => "0"
+ };
+ }
+
+ return " -abr:a 1" + " -b:a " + bitrate;
+ }
+
+ if (string.Equals(encoder, "aac_at", StringComparison.OrdinalIgnoreCase))
+ {
+ // aac_at's CVBR mode
+ return " -aac_at_mode:a 2" + " -b:a " + bitrate;
}
if (string.Equals(encoder, "libvorbis", StringComparison.OrdinalIgnoreCase))
@@ -2626,12 +2677,16 @@ namespace MediaBrowser.Controller.MediaEncoding
&& channels.Value == 2
&& state.AudioStream is not null
&& state.AudioStream.Channels.HasValue
- && state.AudioStream.Channels.Value > 5)
+ && state.AudioStream.Channels.Value == 6)
{
+ if (!encodingOptions.DownMixAudioBoost.Equals(1))
+ {
+ filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture));
+ }
+
switch (encodingOptions.DownMixStereoAlgorithm)
{
case DownMixStereoAlgorithms.Dave750:
- filters.Add("volume=4.25");
filters.Add("pan=stereo|c0=0.5*c2+0.707*c0+0.707*c4+0.5*c3|c1=0.5*c2+0.707*c1+0.707*c5+0.5*c3");
break;
case DownMixStereoAlgorithms.NightmodeDialogue:
@@ -2639,11 +2694,6 @@ namespace MediaBrowser.Controller.MediaEncoding
break;
case DownMixStereoAlgorithms.None:
default:
- if (!encodingOptions.DownMixAudioBoost.Equals(1))
- {
- filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture));
- }
-
break;
}
}
@@ -2719,7 +2769,20 @@ namespace MediaBrowser.Controller.MediaEncoding
if (state.TranscodingType != TranscodingJobType.Progressive
&& ((resultChannels > 2 && resultChannels < 6) || resultChannels == 7))
{
- resultChannels = 2;
+ // We can let FFMpeg supply an extra LFE channel for 5ch and 7ch to make them 5.1 and 7.1
+ if (resultChannels == 5)
+ {
+ resultChannels = 6;
+ }
+ else if (resultChannels == 7)
+ {
+ resultChannels = 8;
+ }
+ else
+ {
+ // For other weird layout, just downmix to stereo for compatibility
+ resultChannels = 2;
+ }
}
}
@@ -2757,7 +2820,13 @@ namespace MediaBrowser.Controller.MediaEncoding
if (time > 0)
{
- seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(time));
+ // For direct streaming/remuxing, we seek at the exact position of the keyframe
+ // However, ffmpeg will seek to previous keyframe when the exact time is the input
+ // Workaround this by adding 0.5s offset to the seeking time to get the exact keyframe on most videos.
+ // This will help subtitle syncing.
+ var isHlsRemuxing = state.IsVideoRequest && state.TranscodingType is TranscodingJobType.Hls && IsCopyCodec(state.OutputVideoCodec);
+ var seekTick = isHlsRemuxing ? time + 5000000L : time;
+ seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(seekTick));
if (state.IsVideoRequest)
{
@@ -2970,8 +3039,8 @@ namespace MediaBrowser.Controller.MediaEncoding
var scaleW = (double)maximumWidth / outputWidth;
var scaleH = (double)maximumHeight / outputHeight;
var scale = Math.Min(scaleW, scaleH);
- outputWidth = Math.Min(maximumWidth, (int)(outputWidth * scale));
- outputHeight = Math.Min(maximumHeight, (int)(outputHeight * scale));
+ outputWidth = Math.Min(maximumWidth, Convert.ToInt32(outputWidth * scale));
+ outputHeight = Math.Min(maximumHeight, Convert.ToInt32(outputHeight * scale));
}
outputWidth = 2 * (outputWidth / 2);
@@ -3147,7 +3216,9 @@ namespace MediaBrowser.Controller.MediaEncoding
int? requestedMaxHeight)
{
var isV4l2 = string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
+ var isMjpeg = videoEncoder is not null && videoEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var scaleVal = isV4l2 ? 64 : 2;
+ var targetAr = isMjpeg ? "(a*sar)" : "a"; // manually calculate AR when using mjpeg encoder
// If fixed dimensions were supplied
if (requestedWidth.HasValue && requestedHeight.HasValue)
@@ -3176,10 +3247,11 @@ namespace MediaBrowser.Controller.MediaEncoding
return string.Format(
CultureInfo.InvariantCulture,
- @"scale=trunc(min(max(iw\,ih*a)\,min({0}\,{1}*a))/{2})*{2}:trunc(min(max(iw/a\,ih)\,min({0}/a\,{1}))/2)*2",
+ @"scale=trunc(min(max(iw\,ih*{3})\,min({0}\,{1}*{3}))/{2})*{2}:trunc(min(max(iw/{3}\,ih)\,min({0}/{3}\,{1}))/2)*2",
maxWidthParam,
maxHeightParam,
- scaleVal);
+ scaleVal,
+ targetAr);
}
// If a fixed width was requested
@@ -3195,8 +3267,9 @@ namespace MediaBrowser.Controller.MediaEncoding
return string.Format(
CultureInfo.InvariantCulture,
- "scale={0}:trunc(ow/a/2)*2",
- widthParam);
+ "scale={0}:trunc(ow/{1}/2)*2",
+ widthParam,
+ targetAr);
}
// If a fixed height was requested
@@ -3206,9 +3279,10 @@ namespace MediaBrowser.Controller.MediaEncoding
return string.Format(
CultureInfo.InvariantCulture,
- "scale=trunc(oh*a/{1})*{1}:{0}",
+ "scale=trunc(oh*{2}/{1})*{1}:{0}",
heightParam,
- scaleVal);
+ scaleVal,
+ targetAr);
}
// If a max width was requested
@@ -3218,9 +3292,10 @@ namespace MediaBrowser.Controller.MediaEncoding
return string.Format(
CultureInfo.InvariantCulture,
- @"scale=trunc(min(max(iw\,ih*a)\,{0})/{1})*{1}:trunc(ow/a/2)*2",
+ @"scale=trunc(min(max(iw\,ih*{2})\,{0})/{1})*{1}:trunc(ow/{2}/2)*2",
maxWidthParam,
- scaleVal);
+ scaleVal,
+ targetAr);
}
// If a max height was requested
@@ -3230,9 +3305,10 @@ namespace MediaBrowser.Controller.MediaEncoding
return string.Format(
CultureInfo.InvariantCulture,
- @"scale=trunc(oh*a/{1})*{1}:min(max(iw/a\,ih)\,{0})",
+ @"scale=trunc(oh*{2}/{1})*{1}:min(max(iw/{2}\,ih)\,{0})",
maxHeightParam,
- scaleVal);
+ scaleVal,
+ targetAr);
}
return string.Empty;
@@ -3499,6 +3575,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
+ var doToneMap = IsSwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -3512,7 +3589,7 @@ namespace MediaBrowser.Controller.MediaEncoding
/* Make main filters for video stream */
var mainFilters = new List<string>();
- mainFilters.Add(GetOverwriteColorPropertiesParam(state, false));
+ mainFilters.Add(GetOverwriteColorPropertiesParam(state, doToneMap));
// INPUT sw surface(memory/copy-back from vram)
// sw deint
@@ -3535,11 +3612,31 @@ namespace MediaBrowser.Controller.MediaEncoding
// sw scale
mainFilters.Add(swScaleFilter);
- mainFilters.Add("format=" + outFormat);
- // sw tonemap <= TODO: finsh the fast tonemap filter
+ // sw tonemap <= TODO: finish dovi tone mapping
- // OUTPUT yuv420p/nv12 surface(memory)
+ if (doToneMap)
+ {
+ var tonemapArgs = $"tonemapx=tonemap={options.TonemappingAlgorithm}:desat={options.TonemappingDesat}:peak={options.TonemappingPeak}:t=bt709:m=bt709:p=bt709:format={outFormat}";
+
+ if (options.TonemappingParam != 0)
+ {
+ tonemapArgs += $":param={options.TonemappingParam}";
+ }
+
+ if (string.Equals(options.TonemappingRange, "tv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(options.TonemappingRange, "pc", StringComparison.OrdinalIgnoreCase))
+ {
+ tonemapArgs += $":range={options.TonemappingRange}";
+ }
+
+ mainFilters.Add(tonemapArgs);
+ }
+ else
+ {
+ // OUTPUT yuv420p/nv12 surface(memory)
+ mainFilters.Add("format=" + outFormat);
+ }
/* Make sub and overlay filters for subtitle stream */
var subFilters = new List<string>();
@@ -4357,6 +4454,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
// map from qsv to vaapi.
mainFilters.Add("hwmap=derive_device=vaapi");
+ mainFilters.Add("format=vaapi");
}
var tonemapFilter = GetHwTonemapFilter(options, "vaapi", "nv12");
@@ -4366,6 +4464,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
// map from vaapi to qsv.
mainFilters.Add("hwmap=derive_device=qsv");
+ mainFilters.Add("format=qsv");
}
}
@@ -4540,7 +4639,8 @@ namespace MediaBrowser.Controller.MediaEncoding
// prefered vaapi + vulkan filters pipeline
if (_mediaEncoder.IsVaapiDeviceAmd
&& isVaapiVkSupported
- && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop)
+ && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop
+ && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier)
{
// AMD radeonsi path(targeting Polaris/gfx8+), with extra vulkan tonemap and overlay support.
return GetAmdVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder);
@@ -5257,11 +5357,6 @@ namespace MediaBrowser.Controller.MediaEncoding
/* Make main filters for video stream */
var mainFilters = new List<string>();
- // INPUT videotoolbox/memory surface(vram/uma)
- // this will pass-through automatically if in/out format matches.
- mainFilters.Add("format=nv12|p010le|videotoolbox_vld");
- mainFilters.Add("hwupload=derive_device=videotoolbox");
-
// hw deint
if (doDeintH2645)
{
@@ -5323,6 +5418,21 @@ namespace MediaBrowser.Controller.MediaEncoding
overlayFilters.Add("overlay_videotoolbox=eof_action=pass:repeatlast=0");
}
+ var needFiltering = mainFilters.Any(f => !string.IsNullOrEmpty(f)) ||
+ subFilters.Any(f => !string.IsNullOrEmpty(f)) ||
+ overlayFilters.Any(f => !string.IsNullOrEmpty(f));
+
+ // This is a workaround for ffmpeg's hwupload implementation
+ // For VideoToolbox encoders, a hwupload without a valid filter actually consuming its frame
+ // will cause the encoder to produce incorrect frames.
+ if (needFiltering)
+ {
+ // INPUT videotoolbox/memory surface(vram/uma)
+ // this will pass-through automatically if in/out format matches.
+ mainFilters.Insert(0, "format=nv12|p010le|videotoolbox_vld");
+ mainFilters.Insert(0, "hwupload=derive_device=videotoolbox");
+ }
+
return (mainFilters, subFilters, overlayFilters);
}
@@ -5813,16 +5923,29 @@ namespace MediaBrowser.Controller.MediaEncoding
{
var bitDepth = GetVideoColorBitDepth(state);
- // Only HEVC, VP9 and AV1 formats have 10-bit hardware decoder support now.
+ // Only HEVC, VP9 and AV1 formats have 10-bit hardware decoder support for most platforms
if (bitDepth == 10
&& !(string.Equals(videoStream.Codec, "hevc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoStream.Codec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoStream.Codec, "vp9", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase)))
{
- // One exception is that RKMPP decoder can handle H.264 High 10.
- if (!(string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase)
- && string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)))
+ // RKMPP has H.264 Hi10P decoder
+ bool hasHardwareHi10P = string.Equals(options.HardwareAccelerationType, "rkmpp", StringComparison.OrdinalIgnoreCase);
+
+ // VideoToolbox on Apple Silicon has H.264 Hi10P mode enabled after macOS 14.6
+ if (string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase))
+ {
+ var ver = Environment.OSVersion.Version;
+ var arch = RuntimeInformation.OSArchitecture;
+ if (arch.Equals(Architecture.Arm64) && ver >= new Version(14, 6))
+ {
+ hasHardwareHi10P = true;
+ }
+ }
+
+ if (!hasHardwareHi10P
+ && string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase))
{
return null;
}
@@ -7049,7 +7172,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var channels = state.OutputAudioChannels;
- if (channels.HasValue && ((channels.Value != 2 && state.AudioStream.Channels <= 5) || encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))
+ if (channels.HasValue && ((channels.Value != 2 && state.AudioStream?.Channels != 6) || encodingOptions.DownMixStereoAlgorithm == DownMixStereoAlgorithms.None))
{
args += " -ac " + channels.Value;
}
@@ -7057,8 +7180,8 @@ namespace MediaBrowser.Controller.MediaEncoding
var bitrate = state.OutputAudioBitrate;
if (bitrate.HasValue && !LosslessAudioCodecs.Contains(codec, StringComparison.OrdinalIgnoreCase))
{
- var vbrParam = GetAudioVbrModeParam(codec, bitrate.Value / (channels ?? 2));
- if (encodingOptions.EnableAudioVbr && vbrParam is not null)
+ var vbrParam = GetAudioVbrModeParam(codec, bitrate.Value, channels ?? 2);
+ if (encodingOptions.EnableAudioVbr && state.EnableAudioVbrEncoding && vbrParam is not null)
{
args += vbrParam;
}
@@ -7088,8 +7211,8 @@ namespace MediaBrowser.Controller.MediaEncoding
if (bitrate.HasValue && !LosslessAudioCodecs.Contains(outputCodec, StringComparison.OrdinalIgnoreCase))
{
- var vbrParam = GetAudioVbrModeParam(GetAudioEncoder(state), bitrate.Value / (channels ?? 2));
- if (encodingOptions.EnableAudioVbr && vbrParam is not null)
+ var vbrParam = GetAudioVbrModeParam(GetAudioEncoder(state), bitrate.Value, channels ?? 2);
+ if (encodingOptions.EnableAudioVbr && state.EnableAudioVbrEncoding && vbrParam is not null)
{
audioTranscodeParams.Add(vbrParam);
}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index f2a0b906d..72df7151d 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -508,6 +508,8 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
+ public bool EnableAudioVbrEncoding => BaseRequest.EnableAudioVbrEncoding;
+
public int HlsListSize => 0;
public bool EnableBreakOnNonKeyFrames(string videoCodec)
diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs
index e696fa52c..038c6c7f6 100644
--- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs
+++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs
@@ -153,6 +153,7 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <param name="threads">The input/output thread count for ffmpeg.</param>
/// <param name="qualityScale">The qscale value for ffmpeg.</param>
/// <param name="priority">The process priority for the ffmpeg process.</param>
+ /// <param name="enableKeyFrameOnlyExtraction">Whether to only extract key frames.</param>
/// <param name="encodingHelper">EncodingHelper instance.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Directory where images where extracted. A given image made before another will always be named with a lower number.</returns>
@@ -168,6 +169,7 @@ namespace MediaBrowser.Controller.MediaEncoding
int? threads,
int? qualityScale,
ProcessPriorityClass? priority,
+ bool enableKeyFrameOnlyExtraction,
EncodingHelper encodingHelper,
CancellationToken cancellationToken);
@@ -246,6 +248,21 @@ namespace MediaBrowser.Controller.MediaEncoding
IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path);
/// <summary>
+ /// Gets the input path argument from <see cref="EncodingJobInfo"/>.
+ /// </summary>
+ /// <param name="state">The <see cref="EncodingJobInfo"/>.</param>
+ /// <returns>The input path argument.</returns>
+ string GetInputPathArgument(EncodingJobInfo state);
+
+ /// <summary>
+ /// Gets the input path argument.
+ /// </summary>
+ /// <param name="path">The item path.</param>
+ /// <param name="mediaSource">The <see cref="MediaSourceInfo"/>.</param>
+ /// <returns>The input path argument.</returns>
+ string GetInputPathArgument(string path, MediaSourceInfo mediaSource);
+
+ /// <summary>
/// Generates a FFmpeg concat config for the source.
/// </summary>
/// <param name="source">The <see cref="MediaSourceInfo"/>.</param>
diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
index 219da309e..a47d2fa45 100644
--- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
+++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
@@ -33,7 +33,7 @@ namespace MediaBrowser.Controller.Net
SingleWriter = false
});
- private readonly SemaphoreSlim _lock = new(1, 1);
+ private readonly object _activeConnectionsLock = new();
/// <summary>
/// The _active connections.
@@ -126,15 +126,10 @@ namespace MediaBrowser.Controller.Net
InitialDelayMs = dueTimeMs
};
- _lock.Wait();
- try
+ lock (_activeConnectionsLock)
{
_activeConnections.Add((message.Connection, cancellationTokenSource, state));
}
- finally
- {
- _lock.Release();
- }
}
protected void SendData(bool force)
@@ -153,8 +148,7 @@ namespace MediaBrowser.Controller.Net
(IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State)[] tuples;
var now = DateTime.UtcNow;
- await _lock.WaitAsync().ConfigureAwait(false);
- try
+ lock (_activeConnectionsLock)
{
if (_activeConnections.Count == 0)
{
@@ -174,10 +168,6 @@ namespace MediaBrowser.Controller.Net
})
.ToArray();
}
- finally
- {
- _lock.Release();
- }
if (tuples.Length == 0)
{
@@ -240,8 +230,7 @@ namespace MediaBrowser.Controller.Net
/// <param name="message">The message.</param>
private void Stop(WebSocketMessageInfo message)
{
- _lock.Wait();
- try
+ lock (_activeConnectionsLock)
{
var connection = _activeConnections.FirstOrDefault(c => c.Connection == message.Connection);
@@ -250,10 +239,6 @@ namespace MediaBrowser.Controller.Net
DisposeConnection(connection);
}
}
- finally
- {
- _lock.Release();
- }
}
/// <summary>
@@ -283,15 +268,10 @@ namespace MediaBrowser.Controller.Net
Logger.LogError(ex, "Error disposing websocket");
}
- _lock.Wait();
- try
+ lock (_activeConnectionsLock)
{
_activeConnections.Remove(connection);
}
- finally
- {
- _lock.Release();
- }
}
protected virtual async ValueTask DisposeAsyncCore()
@@ -306,18 +286,13 @@ namespace MediaBrowser.Controller.Net
Logger.LogError(ex, "Disposing the message consumer failed");
}
- await _lock.WaitAsync().ConfigureAwait(false);
- try
+ lock (_activeConnectionsLock)
{
- foreach (var connection in _activeConnections.ToArray())
+ foreach (var connection in _activeConnections.ToList())
{
DisposeConnection(connection);
}
}
- finally
- {
- _lock.Release();
- }
}
/// <inheritdoc />
diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs
index bb68a3b6d..038cbd2d6 100644
--- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs
+++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Playlists;
namespace MediaBrowser.Controller.Playlists
@@ -11,18 +12,49 @@ namespace MediaBrowser.Controller.Playlists
public interface IPlaylistManager
{
/// <summary>
- /// Gets the playlists.
+ /// Gets the playlist.
+ /// </summary>
+ /// <param name="playlistId">The playlist identifier.</param>
+ /// <param name="userId">The user identifier.</param>
+ /// <returns>Playlist.</returns>
+ Playlist GetPlaylistForUser(Guid playlistId, Guid userId);
+
+ /// <summary>
+ /// Creates the playlist.
+ /// </summary>
+ /// <param name="request">The <see cref="PlaylistCreationRequest"/>.</param>
+ /// <returns>The created playlist.</returns>
+ Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest request);
+
+ /// <summary>
+ /// Updates a playlist.
+ /// </summary>
+ /// <param name="request">The <see cref="PlaylistUpdateRequest"/>.</param>
+ /// <returns>Task.</returns>
+ Task UpdatePlaylist(PlaylistUpdateRequest request);
+
+ /// <summary>
+ /// Gets all playlists a user has access to.
/// </summary>
/// <param name="userId">The user identifier.</param>
/// <returns>IEnumerable&lt;Playlist&gt;.</returns>
IEnumerable<Playlist> GetPlaylists(Guid userId);
/// <summary>
- /// Creates the playlist.
+ /// Adds a share to the playlist.
+ /// </summary>
+ /// <param name="request">The <see cref="PlaylistUserUpdateRequest"/>.</param>
+ /// <returns>Task.</returns>
+ Task AddUserToShares(PlaylistUserUpdateRequest request);
+
+ /// <summary>
+ /// Removes a share from the playlist.
/// </summary>
- /// <param name="options">The options.</param>
- /// <returns>Task&lt;Playlist&gt;.</returns>
- Task<PlaylistCreationResult> CreatePlaylist(PlaylistCreationRequest options);
+ /// <param name="playlistId">The playlist identifier.</param>
+ /// <param name="userId">The user identifier.</param>
+ /// <param name="share">The share.</param>
+ /// <returns>Task.</returns>
+ Task RemoveUserFromShares(Guid playlistId, Guid userId, PlaylistUserPermissions share);
/// <summary>
/// Adds to playlist.
@@ -31,7 +63,7 @@ namespace MediaBrowser.Controller.Playlists
/// <param name="itemIds">The item ids.</param>
/// <param name="userId">The user identifier.</param>
/// <returns>Task.</returns>
- Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
+ Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
/// <summary>
/// Removes from playlist.
@@ -39,7 +71,7 @@ namespace MediaBrowser.Controller.Playlists
/// <param name="playlistId">The playlist identifier.</param>
/// <param name="entryIds">The entry ids.</param>
/// <returns>Task.</returns>
- Task RemoveFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds);
+ Task RemoveItemFromPlaylistAsync(string playlistId, IEnumerable<string> entryIds);
/// <summary>
/// Gets the playlists folder.
diff --git a/MediaBrowser.Controller/Playlists/Playlist.cs b/MediaBrowser.Controller/Playlists/Playlist.cs
index ca032e7f6..45aefacf6 100644
--- a/MediaBrowser.Controller/Playlists/Playlist.cs
+++ b/MediaBrowser.Controller/Playlists/Playlist.cs
@@ -16,24 +16,23 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Querying;
namespace MediaBrowser.Controller.Playlists
{
public class Playlist : Folder, IHasShares
{
- public static readonly IReadOnlyList<string> SupportedExtensions = new[]
- {
+ public static readonly IReadOnlyList<string> SupportedExtensions =
+ [
".m3u",
".m3u8",
".pls",
".wpl",
".zpl"
- };
+ ];
public Playlist()
{
- Shares = Array.Empty<Share>();
+ Shares = [];
OpenAccess = false;
}
@@ -41,7 +40,7 @@ namespace MediaBrowser.Controller.Playlists
public bool OpenAccess { get; set; }
- public Share[] Shares { get; set; }
+ public IReadOnlyList<PlaylistUserPermissions> Shares { get; set; }
[JsonIgnore]
public bool IsFile => IsPlaylistFile(Path);
@@ -130,10 +129,10 @@ namespace MediaBrowser.Controller.Playlists
protected override List<BaseItem> LoadChildren()
{
// Save a trip to the database
- return new List<BaseItem>();
+ return [];
}
- protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
+ protected override Task ValidateChildrenInternal(IProgress<double> progress, bool recursive, bool refreshChildMetadata, bool allowRemoveRoot, MetadataRefreshOptions refreshOptions, IDirectoryService directoryService, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
@@ -145,7 +144,7 @@ namespace MediaBrowser.Controller.Playlists
protected override IEnumerable<BaseItem> GetNonCachedChildren(IDirectoryService directoryService)
{
- return new List<BaseItem>();
+ return [];
}
public override IEnumerable<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query)
@@ -167,7 +166,7 @@ namespace MediaBrowser.Controller.Playlists
return base.GetChildren(user, true, query);
}
- public static List<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> inputItems, User user, DtoOptions options)
+ public static IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<BaseItem> inputItems, User user, DtoOptions options)
{
if (user is not null)
{
@@ -178,23 +177,23 @@ namespace MediaBrowser.Controller.Playlists
foreach (var item in inputItems)
{
- var playlistItems = GetPlaylistItems(item, user, playlistMediaType, options);
+ var playlistItems = GetPlaylistItems(item, user, options);
list.AddRange(playlistItems);
}
return list;
}
- private static IEnumerable<BaseItem> GetPlaylistItems(BaseItem item, User user, MediaType mediaType, DtoOptions options)
+ private static IEnumerable<BaseItem> GetPlaylistItems(BaseItem item, User user, DtoOptions options)
{
if (item is MusicGenre musicGenre)
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
Recursive = true,
- IncludeItemTypes = new[] { BaseItemKind.Audio },
- GenreIds = new[] { musicGenre.Id },
- OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) },
+ IncludeItemTypes = [BaseItemKind.Audio],
+ GenreIds = [musicGenre.Id],
+ OrderBy = [(ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending)],
DtoOptions = options
});
}
@@ -204,9 +203,9 @@ namespace MediaBrowser.Controller.Playlists
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
Recursive = true,
- IncludeItemTypes = new[] { BaseItemKind.Audio },
- ArtistIds = new[] { musicArtist.Id },
- OrderBy = new[] { (ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending) },
+ IncludeItemTypes = [BaseItemKind.Audio],
+ ArtistIds = [musicArtist.Id],
+ OrderBy = [(ItemSortBy.AlbumArtist, SortOrder.Ascending), (ItemSortBy.Album, SortOrder.Ascending), (ItemSortBy.SortName, SortOrder.Ascending)],
DtoOptions = options
});
}
@@ -217,8 +216,7 @@ namespace MediaBrowser.Controller.Playlists
{
Recursive = true,
IsFolder = false,
- OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
- MediaTypes = new[] { mediaType },
+ MediaTypes = [MediaType.Audio, MediaType.Video],
EnableTotalRecordCount = false,
DtoOptions = options
};
@@ -226,7 +224,7 @@ namespace MediaBrowser.Controller.Playlists
return folder.GetItemList(query);
}
- return new[] { item };
+ return [item];
}
public override bool IsVisible(User user)
@@ -248,12 +246,17 @@ namespace MediaBrowser.Controller.Playlists
}
var shares = Shares;
- if (shares.Length == 0)
+ if (shares.Count == 0)
{
return false;
}
- return shares.Any(share => Guid.TryParse(share.UserId, out var id) && id.Equals(userId));
+ return shares.Any(s => s.UserId.Equals(userId));
+ }
+
+ public override bool CanDelete(User user)
+ {
+ return user.HasPermission(PermissionKind.IsAdministrator) || user.Id.Equals(OwnerUserId);
}
public override bool IsVisibleStandalone(User user)
diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs
index 7fe2f64af..474f09dc5 100644
--- a/MediaBrowser.Controller/Providers/DirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/DirectoryService.cs
@@ -28,6 +28,22 @@ namespace MediaBrowser.Controller.Providers
return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem);
}
+ public List<FileSystemMetadata> GetDirectories(string path)
+ {
+ var list = new List<FileSystemMetadata>();
+ var items = GetFileSystemEntries(path);
+ for (var i = 0; i < items.Length; i++)
+ {
+ var item = items[i];
+ if (item.IsDirectory)
+ {
+ list.Add(item);
+ }
+ }
+
+ return list;
+ }
+
public List<FileSystemMetadata> GetFiles(string path)
{
var list = new List<FileSystemMetadata>();
@@ -46,10 +62,22 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata? GetFile(string path)
{
+ var entry = GetFileSystemEntry(path);
+ return entry is not null && !entry.IsDirectory ? entry : null;
+ }
+
+ public FileSystemMetadata? GetDirectory(string path)
+ {
+ var entry = GetFileSystemEntry(path);
+ return entry is not null && entry.IsDirectory ? entry : null;
+ }
+
+ public FileSystemMetadata? GetFileSystemEntry(string path)
+ {
if (!_fileCache.TryGetValue(path, out var result))
{
- var file = _fileSystem.GetFileInfo(path);
- if (file.Exists)
+ var file = _fileSystem.GetFileSystemInfo(path);
+ if (file?.Exists ?? false)
{
result = file;
_fileCache.TryAdd(path, result);
diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs
index 6d7550ab5..1babf73af 100644
--- a/MediaBrowser.Controller/Providers/IDirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs
@@ -9,10 +9,16 @@ namespace MediaBrowser.Controller.Providers
{
FileSystemMetadata[] GetFileSystemEntries(string path);
+ List<FileSystemMetadata> GetDirectories(string path);
+
List<FileSystemMetadata> GetFiles(string path);
FileSystemMetadata? GetFile(string path);
+ FileSystemMetadata? GetDirectory(string path);
+
+ FileSystemMetadata? GetFileSystemEntry(string path);
+
IReadOnlyList<string> GetFilePaths(string path);
IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false);
diff --git a/MediaBrowser.Controller/Providers/IExternalId.cs b/MediaBrowser.Controller/Providers/IExternalId.cs
index 0d847520d..f451eac6d 100644
--- a/MediaBrowser.Controller/Providers/IExternalId.cs
+++ b/MediaBrowser.Controller/Providers/IExternalId.cs
@@ -1,3 +1,4 @@
+using System;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
@@ -33,6 +34,7 @@ namespace MediaBrowser.Controller.Providers
/// <summary>
/// Gets the URL format string for this id.
/// </summary>
+ [Obsolete("Obsolete in 10.10, to be removed in 10.11")]
string? UrlFormatString { get; }
/// <summary>
diff --git a/MediaBrowser.Controller/Providers/IExternalUrlProvider.cs b/MediaBrowser.Controller/Providers/IExternalUrlProvider.cs
new file mode 100644
index 000000000..86a180627
--- /dev/null
+++ b/MediaBrowser.Controller/Providers/IExternalUrlProvider.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+
+namespace MediaBrowser.Controller.Providers;
+
+/// <summary>
+/// Interface to include related urls for an item.
+/// </summary>
+public interface IExternalUrlProvider
+{
+ /// <summary>
+ /// Gets the external service name.
+ /// </summary>
+ string Name { get; }
+
+ /// <summary>
+ /// Get the list of external urls.
+ /// </summary>
+ /// <param name="item">The item to get external urls for.</param>
+ /// <returns>The list of external urls.</returns>
+ IEnumerable<string> GetExternalUrls(BaseItem item);
+}
diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs
index eb5069b06..38fc5f2cc 100644
--- a/MediaBrowser.Controller/Providers/IProviderManager.cs
+++ b/MediaBrowser.Controller/Providers/IProviderManager.cs
@@ -99,12 +99,14 @@ namespace MediaBrowser.Controller.Providers
/// <param name="metadataProviders">Metadata providers to use.</param>
/// <param name="metadataSavers">Metadata savers to use.</param>
/// <param name="externalIds">External IDs to use.</param>
+ /// <param name="externalUrlProviders">The list of external url providers.</param>
void AddParts(
IEnumerable<IImageProvider> imageProviders,
IEnumerable<IMetadataService> metadataServices,
IEnumerable<IMetadataProvider> metadataProviders,
IEnumerable<IMetadataSaver> metadataSavers,
- IEnumerable<IExternalId> externalIds);
+ IEnumerable<IExternalId> externalIds,
+ IEnumerable<IExternalUrlProvider> externalUrlProviders);
/// <summary>
/// Gets the available remote images.
@@ -141,6 +143,14 @@ namespace MediaBrowser.Controller.Providers
where T : BaseItem;
/// <summary>
+ /// Gets the metadata savers for the provided item.
+ /// </summary>
+ /// <param name="item">The item.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <returns>The metadata savers.</returns>
+ IEnumerable<IMetadataSaver> GetMetadataSavers(BaseItem item, LibraryOptions libraryOptions);
+
+ /// <summary>
/// Gets all metadata plugins.
/// </summary>
/// <returns>IEnumerable{MetadataPlugin}.</returns>
diff --git a/MediaBrowser.Controller/Providers/ItemInfo.cs b/MediaBrowser.Controller/Providers/ItemInfo.cs
index 3a97127ea..be3b25aee 100644
--- a/MediaBrowser.Controller/Providers/ItemInfo.cs
+++ b/MediaBrowser.Controller/Providers/ItemInfo.cs
@@ -11,6 +11,8 @@ namespace MediaBrowser.Controller.Providers
public ItemInfo(BaseItem item)
{
Path = item.Path;
+ ParentId = item.ParentId;
+ IndexNumber = item.IndexNumber;
ContainingFolderPath = item.ContainingFolderPath;
IsInMixedFolder = item.IsInMixedFolder;
@@ -27,6 +29,10 @@ namespace MediaBrowser.Controller.Providers
public string Path { get; set; }
+ public Guid ParentId { get; set; }
+
+ public int? IndexNumber { get; set; }
+
public string ContainingFolderPath { get; set; }
public VideoType VideoType { get; set; }
diff --git a/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs b/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs
index a07b3e898..733d40ba1 100644
--- a/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs
+++ b/MediaBrowser.Controller/Resolvers/IResolverIgnoreRule.cs
@@ -14,6 +14,6 @@ namespace MediaBrowser.Controller.Resolvers
/// <param name="fileInfo">The file information.</param>
/// <param name="parent">The parent BaseItem.</param>
/// <returns>True if the file should be ignored.</returns>
- bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem parent);
+ bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent);
}
}
diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs
index 3a12a56f1..9e3358818 100644
--- a/MediaBrowser.Controller/Session/SessionInfo.cs
+++ b/MediaBrowser.Controller/Session/SessionInfo.cs
@@ -134,6 +134,7 @@ namespace MediaBrowser.Controller.Session
/// <value>The now playing item.</value>
public BaseItemDto NowPlayingItem { get; set; }
+ [JsonIgnore]
public BaseItem FullNowPlayingItem { get; set; }
public BaseItemDto NowViewingItem { get; set; }
@@ -269,9 +270,7 @@ namespace MediaBrowser.Controller.Session
public void AddController(ISessionController controller)
{
- var controllers = SessionControllers.ToList();
- controllers.Add(controller);
- SessionControllers = controllers.ToArray();
+ SessionControllers = [..SessionControllers, controller];
}
public bool ContainsUser(Guid userId)
diff --git a/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs
index a8e2946f1..f00d508bb 100644
--- a/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs
+++ b/MediaBrowser.LocalMetadata/Images/EpisodeLocalImageProvider.cs
@@ -38,19 +38,26 @@ namespace MediaBrowser.LocalMetadata.Images
}
var parentPathFiles = directoryService.GetFiles(parentPath);
+ var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan()).ToString();
- var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan());
+ var images = GetImageFilesFromFolder(nameWithoutExtension, parentPathFiles);
- return GetFilesFromParentFolder(nameWithoutExtension, parentPathFiles);
+ var metadataSubDir = directoryService.GetDirectories(parentPath).FirstOrDefault(d => d.Name.Equals("metadata", StringComparison.Ordinal));
+ if (metadataSubDir is not null)
+ {
+ var files = directoryService.GetFiles(metadataSubDir.FullName);
+ images.AddRange(GetImageFilesFromFolder(nameWithoutExtension, files));
+ }
+
+ return images;
}
- private List<LocalImageInfo> GetFilesFromParentFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> parentPathFiles)
+ private List<LocalImageInfo> GetImageFilesFromFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> filePaths)
{
- var thumbName = string.Concat(filenameWithoutExtension, "-thumb");
-
var list = new List<LocalImageInfo>(1);
+ var thumbName = string.Concat(filenameWithoutExtension, "-thumb");
- foreach (var i in parentPathFiles)
+ foreach (var i in filePaths)
{
if (i.IsDirectory)
{
diff --git a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
index 894aebed4..9aa9c3548 100644
--- a/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
+++ b/MediaBrowser.LocalMetadata/Images/LocalImageProvider.cs
@@ -32,6 +32,7 @@ namespace MediaBrowser.LocalMetadata.Images
"folder",
"poster",
"cover",
+ "jacket",
"default"
};
diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs
index 8a870e0d9..a7e027d94 100644
--- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs
+++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs
@@ -519,7 +519,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
private void FetchFromSharesNode(XmlReader reader, IHasShares item)
{
- var list = new List<Share>();
+ var list = new List<PlaylistUserPermissions>();
reader.MoveToContent();
reader.Read();
@@ -565,7 +565,7 @@ namespace MediaBrowser.LocalMetadata.Parsers
}
}
- item.Shares = list.ToArray();
+ item.Shares = [.. list];
}
/// <summary>
@@ -830,12 +830,12 @@ namespace MediaBrowser.LocalMetadata.Parsers
/// </summary>
/// <param name="reader">The xml reader.</param>
/// <returns>The share.</returns>
- protected Share? GetShare(XmlReader reader)
+ protected PlaylistUserPermissions? GetShare(XmlReader reader)
{
- var item = new Share();
-
reader.MoveToContent();
reader.Read();
+ string? userId = null;
+ var canEdit = false;
// Loop through each element
while (!reader.EOF && reader.ReadState == ReadState.Interactive)
@@ -845,10 +845,10 @@ namespace MediaBrowser.LocalMetadata.Parsers
switch (reader.Name)
{
case "UserId":
- item.UserId = reader.ReadNormalizedString();
+ userId = reader.ReadNormalizedString();
break;
case "CanEdit":
- item.CanEdit = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase);
+ canEdit = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase);
break;
default:
reader.Skip();
@@ -862,9 +862,9 @@ namespace MediaBrowser.LocalMetadata.Parsers
}
// This is valid
- if (!string.IsNullOrWhiteSpace(item.UserId))
+ if (!string.IsNullOrWhiteSpace(userId) && Guid.TryParse(userId, out var guid))
{
- return item;
+ return new PlaylistUserPermissions(guid, canEdit);
}
return null;
diff --git a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs
index 5a7193079..ee0d10bea 100644
--- a/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs
+++ b/MediaBrowser.LocalMetadata/Savers/BaseXmlSaver.cs
@@ -420,19 +420,16 @@ namespace MediaBrowser.LocalMetadata.Savers
foreach (var share in item.Shares)
{
- if (share.UserId is not null)
- {
- await writer.WriteStartElementAsync(null, "Share", null).ConfigureAwait(false);
+ await writer.WriteStartElementAsync(null, "Share", null).ConfigureAwait(false);
- await writer.WriteElementStringAsync(null, "UserId", null, share.UserId).ConfigureAwait(false);
- await writer.WriteElementStringAsync(
- null,
- "CanEdit",
- null,
- share.CanEdit.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()).ConfigureAwait(false);
+ await writer.WriteElementStringAsync(null, "UserId", null, share.UserId.ToString()).ConfigureAwait(false);
+ await writer.WriteElementStringAsync(
+ null,
+ "CanEdit",
+ null,
+ share.CanEdit.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()).ConfigureAwait(false);
- await writer.WriteEndElementAsync().ConfigureAwait(false);
- }
+ await writer.WriteEndElementAsync().ConfigureAwait(false);
}
await writer.WriteEndElementAsync().ConfigureAwait(false);
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index a11440ced..914990558 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -89,15 +89,28 @@ namespace MediaBrowser.MediaEncoding.Attachments
string outputPath,
CancellationToken cancellationToken)
{
- using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
+ var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase));
+ if (shouldExtractOneByOne)
{
- if (!Directory.Exists(outputPath))
+ var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index);
+ foreach (var i in attachmentIndexes)
{
- await ExtractAllAttachmentsInternal(
- _mediaEncoder.GetInputArgument(inputFile, mediaSource),
- outputPath,
- false,
- cancellationToken).ConfigureAwait(false);
+ var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture));
+ await ExtractAttachment(inputFile, mediaSource, i, newName, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
+ {
+ if (!Directory.Exists(outputPath))
+ {
+ await ExtractAllAttachmentsInternal(
+ _mediaEncoder.GetInputArgument(inputFile, mediaSource),
+ outputPath,
+ false,
+ cancellationToken).ConfigureAwait(false);
+ }
}
}
}
@@ -247,7 +260,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
MediaSourceInfo mediaSource,
CancellationToken cancellationToken)
{
- var outputFileLocks = new List<AsyncKeyedLockReleaser<string>>();
+ var outputFileLocks = new List<IDisposable>();
var extractableAttachmentIds = new List<int>();
try
@@ -256,16 +269,15 @@ namespace MediaBrowser.MediaEncoding.Attachments
{
var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, attachment.Index);
- var @outputFileLock = _semaphoreLocks.GetOrAdd(outputPath);
- await @outputFileLock.SemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
+ var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
if (File.Exists(outputPath))
{
- @outputFileLock.Dispose();
+ releaser.Dispose();
continue;
}
- outputFileLocks.Add(@outputFileLock);
+ outputFileLocks.Add(releaser);
extractableAttachmentIds.Add(attachment.Index);
}
@@ -280,10 +292,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
}
finally
{
- foreach (var @outputFileLock in outputFileLocks)
- {
- @outputFileLock.Dispose();
- }
+ outputFileLocks.ForEach(x => x.Dispose());
}
}
diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs
index 8ebb59c59..6ca994fb7 100644
--- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs
+++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoExaminer.cs
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Linq;
using BDInfo;
+using Jellyfin.Extensions;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
@@ -60,21 +62,20 @@ public class BdInfoExaminer : IBlurayExaminer
var sortedStreams = playlist.SortedStreams;
var mediaStreams = new List<MediaStream>(sortedStreams.Count);
- foreach (var stream in sortedStreams)
+ for (int i = 0; i < sortedStreams.Count; i++)
{
+ var stream = sortedStreams[i];
switch (stream)
{
case TSVideoStream videoStream:
- AddVideoStream(mediaStreams, videoStream);
+ AddVideoStream(mediaStreams, i, videoStream);
break;
case TSAudioStream audioStream:
- AddAudioStream(mediaStreams, audioStream);
+ AddAudioStream(mediaStreams, i, audioStream);
break;
- case TSTextStream textStream:
- AddSubtitleStream(mediaStreams, textStream);
- break;
- case TSGraphicsStream graphicStream:
- AddSubtitleStream(mediaStreams, graphicStream);
+ case TSTextStream:
+ case TSGraphicsStream:
+ AddSubtitleStream(mediaStreams, i, stream);
break;
}
}
@@ -86,7 +87,7 @@ public class BdInfoExaminer : IBlurayExaminer
if (playlist.StreamClips is not null && playlist.StreamClips.Count > 0)
{
// Get the files in the playlist
- outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.Name).ToArray();
+ outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.FileInfo.FullName).ToArray();
}
return outputStream;
@@ -96,18 +97,19 @@ public class BdInfoExaminer : IBlurayExaminer
/// Adds the video stream.
/// </summary>
/// <param name="streams">The streams.</param>
+ /// <param name="index">The stream index.</param>
/// <param name="videoStream">The video stream.</param>
- private void AddVideoStream(List<MediaStream> streams, TSVideoStream videoStream)
+ private void AddVideoStream(List<MediaStream> streams, int index, TSVideoStream videoStream)
{
var mediaStream = new MediaStream
{
BitRate = Convert.ToInt32(videoStream.BitRate),
Width = videoStream.Width,
Height = videoStream.Height,
- Codec = videoStream.CodecShortName,
+ Codec = GetNormalizedCodec(videoStream),
IsInterlaced = videoStream.IsInterlaced,
Type = MediaStreamType.Video,
- Index = streams.Count
+ Index = index
};
if (videoStream.FrameRateDenominator > 0)
@@ -125,17 +127,19 @@ public class BdInfoExaminer : IBlurayExaminer
/// Adds the audio stream.
/// </summary>
/// <param name="streams">The streams.</param>
+ /// <param name="index">The stream index.</param>
/// <param name="audioStream">The audio stream.</param>
- private void AddAudioStream(List<MediaStream> streams, TSAudioStream audioStream)
+ private void AddAudioStream(List<MediaStream> streams, int index, TSAudioStream audioStream)
{
var stream = new MediaStream
{
- Codec = audioStream.CodecShortName,
+ Codec = GetNormalizedCodec(audioStream),
Language = audioStream.LanguageCode,
- Channels = audioStream.ChannelCount,
+ ChannelLayout = string.Format(CultureInfo.InvariantCulture, "{0:D}.{1:D}", audioStream.ChannelCount, audioStream.LFE),
+ Channels = audioStream.ChannelCount + audioStream.LFE,
SampleRate = audioStream.SampleRate,
Type = MediaStreamType.Audio,
- Index = streams.Count
+ Index = index
};
var bitrate = Convert.ToInt32(audioStream.BitRate);
@@ -145,11 +149,6 @@ public class BdInfoExaminer : IBlurayExaminer
stream.BitRate = bitrate;
}
- if (audioStream.LFE > 0)
- {
- stream.Channels = audioStream.ChannelCount + 1;
- }
-
streams.Add(stream);
}
@@ -157,31 +156,28 @@ public class BdInfoExaminer : IBlurayExaminer
/// Adds the subtitle stream.
/// </summary>
/// <param name="streams">The streams.</param>
- /// <param name="textStream">The text stream.</param>
- private void AddSubtitleStream(List<MediaStream> streams, TSTextStream textStream)
+ /// <param name="index">The stream index.</param>
+ /// <param name="stream">The stream.</param>
+ private void AddSubtitleStream(List<MediaStream> streams, int index, TSStream stream)
{
streams.Add(new MediaStream
{
- Language = textStream.LanguageCode,
- Codec = textStream.CodecShortName,
+ Language = stream.LanguageCode,
+ Codec = GetNormalizedCodec(stream),
Type = MediaStreamType.Subtitle,
- Index = streams.Count
+ Index = index
});
}
- /// <summary>
- /// Adds the subtitle stream.
- /// </summary>
- /// <param name="streams">The streams.</param>
- /// <param name="textStream">The text stream.</param>
- private void AddSubtitleStream(List<MediaStream> streams, TSGraphicsStream textStream)
- {
- streams.Add(new MediaStream
+ private string GetNormalizedCodec(TSStream stream)
+ => stream.StreamType switch
{
- Language = textStream.LanguageCode,
- Codec = textStream.CodecShortName,
- Type = MediaStreamType.Subtitle,
- Index = streams.Count
- });
- }
+ TSStreamType.MPEG1_VIDEO => "mpeg1video",
+ TSStreamType.MPEG2_VIDEO => "mpeg2video",
+ TSStreamType.VC1_VIDEO => "vc1",
+ TSStreamType.AC3_PLUS_AUDIO or TSStreamType.AC3_PLUS_SECONDARY_AUDIO => "eac3",
+ TSStreamType.DTS_AUDIO or TSStreamType.DTS_HD_AUDIO or TSStreamType.DTS_HD_MASTER_AUDIO or TSStreamType.DTS_HD_SECONDARY_AUDIO => "dts",
+ TSStreamType.PRESENTATION_GRAPHICS => "pgssub",
+ _ => stream.CodecShortName
+ };
}
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
index 7226e0bf1..eb5d88de6 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs
@@ -27,6 +27,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
"msmpeg4",
"dca",
"ac3",
+ "ac4",
"aac",
"mp3",
"flac",
@@ -69,6 +70,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
"aac_at",
"libfdk_aac",
"ac3",
+ "alac",
"dca",
"libmp3lame",
"libopus",
@@ -81,6 +83,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
"av1_amf",
"h264_qsv",
"hevc_qsv",
+ "mjpeg_qsv",
"av1_qsv",
"h264_nvenc",
"hevc_nvenc",
@@ -88,9 +91,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
"h264_vaapi",
"hevc_vaapi",
"av1_vaapi",
+ "mjpeg_vaapi",
"h264_v4l2m2m",
"h264_videotoolbox",
"hevc_videotoolbox",
+ "mjpeg_videotoolbox",
"h264_rkmpp",
"hevc_rkmpp"
};
@@ -100,6 +105,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
// sw
"alphasrc",
"zscale",
+ "tonemapx",
// qsv
"scale_qsv",
"vpp_qsv",
@@ -502,6 +508,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
return output.Contains(keyDesc, StringComparison.Ordinal);
}
+ public bool CheckSupportedHwaccelFlag(string flag)
+ {
+ return !string.IsNullOrEmpty(flag) && GetProcessExitCode(_encoderPath, $"-loglevel quiet -hwaccel_flags +{flag} -hide_banner -f lavfi -i nullsrc=s=1x1:d=100 -f null -");
+ }
+
private IEnumerable<string> GetCodecs(Codec codec)
{
string codecstr = codec == Codec.Encoder ? "encoders" : "decoders";
@@ -607,6 +618,31 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
}
+ private bool GetProcessExitCode(string path, string arguments)
+ {
+ using var process = new Process();
+ process.StartInfo = new ProcessStartInfo(path, arguments)
+ {
+ CreateNoWindow = true,
+ UseShellExecute = false,
+ WindowStyle = ProcessWindowStyle.Hidden,
+ ErrorDialog = false
+ };
+ _logger.LogDebug("Running {Path} {Arguments}", path, arguments);
+
+ try
+ {
+ process.Start();
+ process.WaitForExit();
+ return process.ExitCode == 0;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError("Running {Path} {Arguments} failed with exception {Exception}", path, arguments, ex.Message);
+ return false;
+ }
+ }
+
[GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
private static partial Regex CodecRegex();
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 6c43315a8..5cfead502 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -30,10 +30,8 @@ using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
-using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
-using static Nikse.SubtitleEdit.Core.Common.IfoParser;
namespace MediaBrowser.MediaEncoding.Encoder
{
@@ -76,6 +74,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
private IDictionary<int, bool> _filtersWithOption = new Dictionary<int, bool>();
private bool _isPkeyPauseSupported = false;
+ private bool _isLowPriorityHwDecodeSupported = false;
private bool _isVaapiDeviceAmd = false;
private bool _isVaapiDeviceInteliHD = false;
@@ -196,6 +195,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
_threads = EncodingHelper.GetNumberOfThreads(null, options, null);
_isPkeyPauseSupported = validator.CheckSupportedRuntimeKey("p pause transcoding");
+ _isLowPriorityHwDecodeSupported = validator.CheckSupportedHwaccelFlag("low_priority");
// Check the Vaapi device vendor
if (OperatingSystem.IsLinux()
@@ -458,9 +458,14 @@ namespace MediaBrowser.MediaEncoding.Encoder
extraArgs += " -probesize " + ffmpegProbeSize;
}
- if (request.MediaSource.RequiredHttpHeaders.TryGetValue("user_agent", out var userAgent))
+ if (request.MediaSource.RequiredHttpHeaders.TryGetValue("User-Agent", out var userAgent))
{
- extraArgs += " -user_agent " + userAgent;
+ extraArgs += $" -user_agent \"{userAgent}\"";
+ }
+
+ if (request.MediaSource.Protocol == MediaProtocol.Rtsp)
+ {
+ extraArgs += " -rtsp_transport tcp+udp -rtsp_flags prefer_tcp";
}
return extraArgs;
@@ -616,7 +621,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
ImageFormat? targetFormat,
CancellationToken cancellationToken)
{
- var inputArgument = GetInputArgument(inputFile, mediaSource);
+ var inputArgument = GetInputPathArgument(inputFile, mediaSource);
if (!isAudio)
{
@@ -705,16 +710,22 @@ namespace MediaBrowser.MediaEncoding.Encoder
filters.Add("thumbnail=n=" + (useLargerBatchSize ? "50" : "24"));
}
- // Use SW tonemap on HDR10/HLG video stream only when the zscale filter is available.
+ // Use SW tonemap on HDR10/HLG video stream only when the zscale or tonemapx filter is available.
var enableHdrExtraction = false;
- if ((string.Equals(videoStream?.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)
+ if (string.Equals(videoStream?.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoStream?.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase))
- && SupportsFilter("zscale"))
{
- 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");
+ if (SupportsFilter("tonemapx"))
+ {
+ enableHdrExtraction = true;
+ filters.Add("tonemapx=tonemap=bt2390:desat=0:peak=100:t=bt709:m=bt709:p=bt709:format=yuv420p");
+ }
+ else if (SupportsFilter("zscale"))
+ {
+ 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");
+ }
}
var vf = string.Join(',', filters);
@@ -804,12 +815,28 @@ namespace MediaBrowser.MediaEncoding.Encoder
int? threads,
int? qualityScale,
ProcessPriorityClass? priority,
+ bool enableKeyFrameOnlyExtraction,
EncodingHelper encodingHelper,
CancellationToken cancellationToken)
{
var options = allowHwAccel ? _configurationManager.GetEncodingOptions() : new EncodingOptions();
threads ??= _threads;
+ if (allowHwAccel && enableKeyFrameOnlyExtraction)
+ {
+ var supportsKeyFrameOnly = (string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && options.EnableEnhancedNvdecDecoder)
+ || (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && OperatingSystem.IsWindows())
+ || (string.Equals(options.HardwareAccelerationType, "qsv", StringComparison.OrdinalIgnoreCase) && options.PreferSystemNativeHwDecoder)
+ || string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(options.HardwareAccelerationType, "videotoolbox", StringComparison.OrdinalIgnoreCase);
+ if (!supportsKeyFrameOnly)
+ {
+ // Disable hardware acceleration when the hardware decoder does not support keyframe only mode.
+ allowHwAccel = false;
+ options = new EncodingOptions();
+ }
+ }
+
// A new EncodingOptions instance must be used as to not disable HW acceleration for all of Jellyfin.
// Additionally, we must set a few fields without defaults to prevent null pointer exceptions.
if (!allowHwAccel)
@@ -819,6 +846,22 @@ namespace MediaBrowser.MediaEncoding.Encoder
options.EnableTonemapping = false;
}
+ if (imageStream.Width is not null && imageStream.Height is not null && !string.IsNullOrEmpty(imageStream.AspectRatio))
+ {
+ // For hardware trickplay encoders, we need to re-calculate the size because they used fixed scale dimensions
+ var darParts = imageStream.AspectRatio.Split(':');
+ var (wa, ha) = (double.Parse(darParts[0], CultureInfo.InvariantCulture), double.Parse(darParts[1], CultureInfo.InvariantCulture));
+ // When dimension / DAR does not equal to 1:1, then the frames are most likely stored stretched.
+ // Note: this might be incorrect for 3D videos as the SAR stored might be per eye instead of per video, but we really can do little about it.
+ var shouldResetHeight = Math.Abs((imageStream.Width.Value * ha) - (imageStream.Height.Value * wa)) > .05;
+ if (shouldResetHeight)
+ {
+ // SAR = DAR * Height / Width
+ // RealHeight = Height / SAR = Height / (DAR * Height / Width) = Width / DAR
+ imageStream.Height = Convert.ToInt32(imageStream.Width.Value * ha / wa);
+ }
+ }
+
var baseRequest = new BaseEncodingJobOptions { MaxWidth = maxWidth, MaxFramerate = (float)(1.0 / interval.TotalSeconds) };
var jobState = new EncodingJobInfo(TranscodingJobType.Progressive)
{
@@ -843,7 +886,18 @@ namespace MediaBrowser.MediaEncoding.Encoder
inputArg = "-threads " + threads + " " + inputArg; // HW accel args set a different input thread count, only set if disabled
}
- var filterParam = encodingHelper.GetVideoProcessingFilterParam(jobState, options, jobState.OutputVideoCodec).Trim();
+ if (options.HardwareAccelerationType.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase) && _isLowPriorityHwDecodeSupported)
+ {
+ // VideoToolbox supports low priority decoding, which is useful for trickplay
+ 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.");
@@ -866,6 +920,23 @@ namespace MediaBrowser.MediaEncoding.Encoder
throw new InvalidOperationException("Empty or invalid input argument.");
}
+ float? encoderQuality = qualityScale;
+ if (vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase))
+ {
+ // vaapi's mjpeg encoder uses jpeg quality divided by QP2LAMBDA (118) as input, instead of ffmpeg defined qscale
+ // ffmpeg qscale is a value from 1-31, with 1 being best quality and 31 being worst
+ // jpeg quality is a value from 0-100, with 0 being worst quality and 100 being best
+ encoderQuality = (100 - ((qualityScale - 1) * (100 / 30))) / 118;
+ }
+
+ if (vidEncoder.Contains("videotoolbox", StringComparison.OrdinalIgnoreCase))
+ {
+ // videotoolbox's mjpeg encoder uses jpeg quality scaled to QP2LAMBDA (118) instead of ffmpeg defined qscale
+ // ffmpeg qscale is a value from 1-31, with 1 being best quality and 31 being worst
+ // jpeg quality is a value from 0-100, with 0 being worst quality and 100 being best
+ encoderQuality = 118 - ((qualityScale - 1) * (118 / 30));
+ }
+
// Output arguments
var targetDirectory = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(targetDirectory);
@@ -874,12 +945,13 @@ namespace MediaBrowser.MediaEncoding.Encoder
// Final command arguments
var args = string.Format(
CultureInfo.InvariantCulture,
- "-loglevel error {0} -an -sn {1} -threads {2} -c:v {3} {4}-f {5} \"{6}\"",
+ "-loglevel error {0} -an -sn {1} -threads {2} -c:v {3} {4}{5}-f {6} \"{7}\"",
inputArg,
filterParam,
outputThreads.GetValueOrDefault(_threads),
vidEncoder,
- qualityScale.HasValue ? "-qscale:v " + qualityScale.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty,
+ qualityScale.HasValue ? "-qscale:v " + encoderQuality.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty,
+ vidEncoder.Contains("videotoolbox", StringComparison.InvariantCultureIgnoreCase) ? "-allow_sw 1 " : string.Empty, // allow_sw fallback for some intel macs
"image2",
outputPath);
@@ -931,7 +1003,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
var timeoutMs = _configurationManager.Configuration.ImageExtractionTimeoutMs;
timeoutMs = timeoutMs <= 0 ? DefaultHdrImageExtractionTimeout : timeoutMs;
- while (isResponsive)
+ while (isResponsive && !cancellationToken.IsCancellationRequested)
{
try
{
@@ -945,8 +1017,6 @@ namespace MediaBrowser.MediaEncoding.Encoder
// We don't actually expect the process to be finished in one timeout span, just that one image has been generated.
}
- cancellationToken.ThrowIfCancellationRequested();
-
var jpegCount = _fileSystem.GetFilePaths(targetDirectory).Count();
isResponsive = jpegCount > lastCount;
@@ -955,7 +1025,12 @@ namespace MediaBrowser.MediaEncoding.Encoder
if (!ranToCompletion)
{
- _logger.LogInformation("Stopping trickplay extraction due to process inactivity.");
+ if (!isResponsive)
+ {
+ _logger.LogInformation("Trickplay process unresponsive.");
+ }
+
+ _logger.LogInformation("Stopping trickplay extraction.");
StopProcess(processWrapper, 1000);
}
}
@@ -1118,19 +1193,21 @@ namespace MediaBrowser.MediaEncoding.Encoder
/// <inheritdoc />
public IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path)
- {
- // Get all playable .m2ts files
- var validPlaybackFiles = _blurayExaminer.GetDiscInfo(path).Files;
+ => _blurayExaminer.GetDiscInfo(path).Files;
- // Get all files from the BDMV/STREAMING directory
- var directoryFiles = _fileSystem.GetFiles(Path.Join(path, "BDMV", "STREAM"));
+ /// <inheritdoc />
+ public string GetInputPathArgument(EncodingJobInfo state)
+ => GetInputPathArgument(state.MediaPath, state.MediaSource);
- // Only return playable local .m2ts files
- return directoryFiles
- .Where(f => validPlaybackFiles.Contains(f.Name, StringComparer.OrdinalIgnoreCase))
- .Select(f => f.FullName)
- .Order()
- .ToList();
+ /// <inheritdoc />
+ public string GetInputPathArgument(string path, MediaSourceInfo mediaSource)
+ {
+ return mediaSource.VideoType switch
+ {
+ VideoType.Dvd => GetInputArgument(GetPrimaryPlaylistVobFiles(path, null), mediaSource),
+ VideoType.BluRay => GetInputArgument(GetPrimaryPlaylistM2tsFiles(path), mediaSource),
+ _ => GetInputArgument(path, mediaSource)
+ };
}
/// <inheritdoc />
@@ -1153,6 +1230,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
// Generate concat configuration entries for each file and write to file
+ Directory.CreateDirectory(Path.GetDirectoryName(concatFilePath));
using StreamWriter sw = new StreamWriter(concatFilePath);
foreach (var path in files)
{
@@ -1172,7 +1250,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds;
// Add file path stanza to concat configuration
- sw.WriteLine("file '{0}'", path);
+ sw.WriteLine("file '{0}'", path.Replace("'", @"'\''", StringComparison.Ordinal));
// Add duration stanza to concat configuration
sw.WriteLine("duration {0}", duration);
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 0a42d504a..f68406910 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -280,8 +280,8 @@ namespace MediaBrowser.MediaEncoding.Probing
splitFormat[i] = "mpeg";
}
- // Handle MPEG-2 container
- else if (string.Equals(splitFormat[i], "mpeg", StringComparison.OrdinalIgnoreCase))
+ // Handle MPEG-TS container
+ else if (string.Equals(splitFormat[i], "mpegts", StringComparison.OrdinalIgnoreCase))
{
splitFormat[i] = "ts";
}
@@ -624,15 +624,19 @@ namespace MediaBrowser.MediaEncoding.Probing
{
if (string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase))
{
- codec = "dvbsub";
+ codec = "DVBSUB";
}
- else if ((codec ?? string.Empty).Contains("PGS", StringComparison.OrdinalIgnoreCase))
+ else if (string.Equals(codec, "dvb_teletext", StringComparison.OrdinalIgnoreCase))
{
- codec = "PGSSUB";
+ codec = "DVBTXT";
}
- else if ((codec ?? string.Empty).Contains("DVD", StringComparison.OrdinalIgnoreCase))
+ else if (string.Equals(codec, "dvd_subtitle", StringComparison.OrdinalIgnoreCase))
{
- codec = "DVDSUB";
+ codec = "DVDSUB"; // .sub+.idx
+ }
+ else if (string.Equals(codec, "hdmv_pgs_subtitle", StringComparison.OrdinalIgnoreCase))
+ {
+ codec = "PGSSUB"; // .sup
}
return codec;
@@ -717,6 +721,8 @@ namespace MediaBrowser.MediaEncoding.Probing
if (streamInfo.CodecType == CodecType.Audio)
{
stream.Type = MediaStreamType.Audio;
+ stream.LocalizedDefault = _localization.GetLocalizedString("Default");
+ stream.LocalizedExternal = _localization.GetLocalizedString("External");
stream.Channels = streamInfo.Channels;
@@ -779,11 +785,10 @@ namespace MediaBrowser.MediaEncoding.Probing
&& !string.Equals(streamInfo.FieldOrder, "progressive", StringComparison.OrdinalIgnoreCase);
if (isAudio
- && (string.Equals(stream.Codec, "bmp", StringComparison.OrdinalIgnoreCase)
- || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase)
- || string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)
- || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)
- || string.Equals(stream.Codec, "webp", StringComparison.OrdinalIgnoreCase)))
+ || string.Equals(stream.Codec, "bmp", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(stream.Codec, "webp", StringComparison.OrdinalIgnoreCase))
{
stream.Type = MediaStreamType.EmbeddedImage;
}
@@ -1320,23 +1325,38 @@ namespace MediaBrowser.MediaEncoding.Probing
// These support multiple values, but for now we only store the first.
var mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Artist Id"))
?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMARTISTID"));
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb);
+ if (!string.IsNullOrEmpty(mb))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, mb);
+ }
mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Artist Id"))
?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ARTISTID"));
- audio.SetProviderId(MetadataProvider.MusicBrainzArtist, mb);
+ if (!string.IsNullOrEmpty(mb))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzArtist, mb);
+ }
mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Album Id"))
?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_ALBUMID"));
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, mb);
+ if (!string.IsNullOrEmpty(mb))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, mb);
+ }
mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Group Id"))
?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASEGROUPID"));
- audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb);
+ if (!string.IsNullOrEmpty(mb))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, mb);
+ }
mb = GetMultipleMusicBrainzId(tags.GetValueOrDefault("MusicBrainz Release Track Id"))
?? GetMultipleMusicBrainzId(tags.GetValueOrDefault("MUSICBRAINZ_RELEASETRACKID"));
- audio.SetProviderId(MetadataProvider.MusicBrainzTrack, mb);
+ if (!string.IsNullOrEmpty(mb))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzTrack, mb);
+ }
}
private string GetMultipleMusicBrainzId(string value)
@@ -1346,9 +1366,8 @@ namespace MediaBrowser.MediaEncoding.Probing
return null;
}
- return value.Split('/', StringSplitOptions.RemoveEmptyEntries)
- .Select(i => i.Trim())
- .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i));
+ return value.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .FirstOrDefault();
}
/// <summary>
@@ -1357,17 +1376,13 @@ namespace MediaBrowser.MediaEncoding.Probing
/// <param name="val">The val.</param>
/// <param name="allowCommaDelimiter">if set to <c>true</c> [allow comma delimiter].</param>
/// <returns>System.String[][].</returns>
- private IEnumerable<string> Split(string val, bool allowCommaDelimiter)
+ private string[] Split(string val, bool allowCommaDelimiter)
{
// Only use the comma as a delimiter if there are no slashes or pipes.
// We want to be careful not to split names that have commas in them
- var delimiter = !allowCommaDelimiter || _nameDelimiters.Any(i => val.Contains(i, StringComparison.Ordinal)) ?
- _nameDelimiters :
- new[] { ',' };
-
- return val.Split(delimiter, StringSplitOptions.RemoveEmptyEntries)
- .Where(i => !string.IsNullOrWhiteSpace(i))
- .Select(i => i.Trim());
+ return !allowCommaDelimiter || _nameDelimiters.Any(i => val.Contains(i, StringComparison.Ordinal)) ?
+ val.Split(_nameDelimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) :
+ val.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private IEnumerable<string> SplitDistinctArtists(string val, char[] delimiters, bool splitFeaturing)
@@ -1391,9 +1406,7 @@ namespace MediaBrowser.MediaEncoding.Probing
}
}
- var artists = val.Split(delimiters, StringSplitOptions.RemoveEmptyEntries)
- .Where(i => !string.IsNullOrWhiteSpace(i))
- .Select(i => i.Trim());
+ var artists = val.Split(delimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
artistsFound.AddRange(artists);
return artistsFound.DistinctNames();
@@ -1518,15 +1531,12 @@ namespace MediaBrowser.MediaEncoding.Probing
if (tags.TryGetValue("WM/Genre", out var genres) && !string.IsNullOrWhiteSpace(genres))
{
- var genreList = genres.Split(new[] { ';', '/', ',' }, StringSplitOptions.RemoveEmptyEntries)
- .Where(i => !string.IsNullOrWhiteSpace(i))
- .Select(i => i.Trim())
- .ToList();
+ var genreList = genres.Split(new[] { ';', '/', ',' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// If this is empty then don't overwrite genres that might have been fetched earlier
- if (genreList.Count > 0)
+ if (genreList.Length > 0)
{
- video.Genres = genreList.ToArray();
+ video.Genres = genreList;
}
}
@@ -1537,10 +1547,9 @@ namespace MediaBrowser.MediaEncoding.Probing
if (tags.TryGetValue("WM/MediaCredits", out var people) && !string.IsNullOrEmpty(people))
{
- video.People = people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries)
- .Where(i => !string.IsNullOrWhiteSpace(i))
- .Select(i => new BaseItemPerson { Name = i.Trim(), Type = PersonKind.Actor })
- .ToArray();
+ video.People = Array.ConvertAll(
+ people.Split(new[] { ';', '/' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
+ i => new BaseItemPerson { Name = i, Type = PersonKind.Actor });
}
if (tags.TryGetValue("WM/OriginalReleaseTime", out var year) && int.TryParse(year, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedYear))
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 4b1b1bbc6..9ecbfa9cf 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -198,10 +198,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
if (!subtitleStream.IsExternal || subtitleStream.Path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
- await ExtractAllTextSubtitles(mediaSource, cancellationToken).ConfigureAwait(false);
+ await ExtractAllExtractableSubtitles(mediaSource, cancellationToken).ConfigureAwait(false);
- var outputFormat = GetTextSubtitleFormat(subtitleStream);
- var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFormat);
+ var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream);
+ var outputFormat = GetExtractableSubtitleFormat(subtitleStream);
+ var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension);
return new SubtitleInfo()
{
@@ -215,6 +216,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var currentFormat = (Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec)
.TrimStart('.');
+ // Handle PGS subtitles as raw streams for the client to render
+ if (MediaStream.IsPgsFormat(currentFormat))
+ {
+ return new SubtitleInfo()
+ {
+ Path = subtitleStream.Path,
+ Protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path),
+ Format = "pgssub",
+ IsExternal = true
+ };
+ }
+
// Fallback to ffmpeg conversion
if (!_subtitleParser.SupportsFileExtension(currentFormat))
{
@@ -428,10 +441,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
_logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
}
- private string GetTextSubtitleFormat(MediaStream subtitleStream)
+ private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
{
if (string.Equals(subtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase)
- || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(subtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
{
return subtitleStream.Codec;
}
@@ -441,50 +455,63 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
+ private string GetExtractableSubtitleFileExtension(MediaStream subtitleStream)
+ {
+ // Using .pgssub as file extension is not allowed by ffmpeg. The file extension for pgs subtitles is .sup.
+ if (string.Equals(subtitleStream.Codec, "pgssub", StringComparison.OrdinalIgnoreCase))
+ {
+ return "sup";
+ }
+ else
+ {
+ return GetExtractableSubtitleFormat(subtitleStream);
+ }
+ }
+
private bool IsCodecCopyable(string codec)
{
return string.Equals(codec, "ass", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "ssa", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "srt", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase);
+ || string.Equals(codec, "subrip", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "pgssub", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
- /// Extracts all text subtitles.
+ /// 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 ExtractAllTextSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken)
+ private async Task ExtractAllExtractableSubtitles(MediaSourceInfo mediaSource, CancellationToken cancellationToken)
{
- var locks = new List<AsyncKeyedLockReleaser<string>>();
+ var locks = new List<IDisposable>();
var extractableStreams = new List<MediaStream>();
try
{
var subtitleStreams = mediaSource.MediaStreams
- .Where(stream => stream.IsTextSubtitleStream && stream.SupportsExternalStream);
+ .Where(stream => stream is { IsExtractableSubtitleStream: true, SupportsExternalStream: true, IsExternal: false });
foreach (var subtitleStream in subtitleStreams)
{
- var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream));
+ var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
- var @lock = _semaphoreLocks.GetOrAdd(outputPath);
- await @lock.SemaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false);
+ var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
if (File.Exists(outputPath))
{
- @lock.Dispose();
+ releaser.Dispose();
continue;
}
- locks.Add(@lock);
+ locks.Add(releaser);
extractableStreams.Add(subtitleStream);
}
if (extractableStreams.Count > 0)
{
- await ExtractAllTextSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
+ await ExtractAllExtractableSubtitlesInternal(mediaSource, extractableStreams, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex)
@@ -493,14 +520,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
finally
{
- foreach (var @lock in locks)
- {
- @lock.Dispose();
- }
+ locks.ForEach(x => x.Dispose());
}
}
- private async Task ExtractAllTextSubtitlesInternal(
+ private async Task ExtractAllExtractableSubtitlesInternal(
MediaSourceInfo mediaSource,
List<MediaStream> subtitleStreams,
CancellationToken cancellationToken)
@@ -514,7 +538,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
foreach (var subtitleStream in subtitleStreams)
{
- var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetTextSubtitleFormat(subtitleStream));
+ var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
diff --git a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
index a07a0f41b..67a2dddb8 100644
--- a/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
+++ b/MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs
@@ -235,15 +235,6 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
if (delete(job.Path!))
{
await DeletePartialStreamFiles(job.Path!, job.Type, 0, 1500).ConfigureAwait(false);
- if (job.MediaSource?.VideoType == VideoType.Dvd || job.MediaSource?.VideoType == VideoType.BluRay)
- {
- var concatFilePath = Path.Join(_serverConfigurationManager.GetTranscodePath(), job.MediaSource.Id + ".concat");
- if (File.Exists(concatFilePath))
- {
- _logger.LogInformation("Deleting ffmpeg concat configuration at {Path}", concatFilePath);
- File.Delete(concatFilePath);
- }
- }
}
if (closeLiveStream && !string.IsNullOrWhiteSpace(job.LiveStreamId))
@@ -419,7 +410,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
var attachmentPath = Path.Combine(_appPaths.CachePath, "attachments", state.MediaSource.Id);
if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
{
- var concatPath = Path.Join(_serverConfigurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat");
+ var concatPath = Path.Join(_appPaths.CachePath, "concat", state.MediaSource.Id + ".concat");
await _attachmentExtractor.ExtractAllAttachments(concatPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false);
}
else
@@ -479,6 +470,11 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
: "FFmpeg.DirectStream-";
}
+ if (state.VideoRequest is null && EncodingHelper.IsCopyCodec(state.OutputAudioCodec))
+ {
+ logFilePrefix = "FFmpeg.Remux-";
+ }
+
var logFilePath = Path.Combine(
_serverConfigurationManager.ApplicationPaths.LogDirectoryPath,
$"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log");
@@ -492,12 +488,11 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
IODefaults.FileStreamBufferSize,
FileOptions.Asynchronous);
- var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments;
+ await JsonSerializer.SerializeAsync(logStream, state.MediaSource, cancellationToken: cancellationTokenSource.Token).ConfigureAwait(false);
var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(
- JsonSerializer.Serialize(state.MediaSource)
- + Environment.NewLine
+ Environment.NewLine
+ Environment.NewLine
- + commandLineLogMessage
+ + process.StartInfo.FileName + " " + process.StartInfo.Arguments
+ Environment.NewLine
+ Environment.NewLine);
diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs
index e777d5fd8..c956bee47 100644
--- a/MediaBrowser.Model/Configuration/LibraryOptions.cs
+++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs
@@ -21,7 +21,7 @@ namespace MediaBrowser.Model.Configuration
AutomaticallyAddToCollection = false;
EnablePhotos = true;
SaveSubtitlesWithMedia = true;
- SaveLyricsWithMedia = true;
+ SaveLyricsWithMedia = false;
PathInfos = Array.Empty<MediaPathInfo>();
EnableAutomaticSeriesGrouping = true;
SeasonZeroDisplayName = "Specials";
@@ -35,8 +35,6 @@ namespace MediaBrowser.Model.Configuration
public bool EnableLUFSScan { get; set; }
- public bool UseReplayGainTags { get; set; }
-
public bool EnableChapterImageExtraction { get; set; }
public bool ExtractChapterImagesDuringLibraryScan { get; set; }
@@ -96,7 +94,7 @@ namespace MediaBrowser.Model.Configuration
public bool SaveSubtitlesWithMedia { get; set; }
- [DefaultValue(true)]
+ [DefaultValue(false)]
public bool SaveLyricsWithMedia { get; set; }
public bool AutomaticallyAddToCollection { get; set; }
diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
index fe92251e9..52f7e53b8 100644
--- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs
+++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
@@ -163,7 +163,7 @@ public class ServerConfiguration : BaseApplicationConfiguration
/// If set to 0 the check for inactive sessions gets disabled.
/// </summary>
/// <value>The close inactive session threshold in minutes. 0 to disable.</value>
- public int InactiveSessionThreshold { get; set; } = 10;
+ public int InactiveSessionThreshold { get; set; }
/// <summary>
/// Gets or sets the delay in seconds that we will wait after a file system change to try and discover what has been added/removed
diff --git a/MediaBrowser.Model/Configuration/TrickplayOptions.cs b/MediaBrowser.Model/Configuration/TrickplayOptions.cs
index a151d3429..578bb306a 100644
--- a/MediaBrowser.Model/Configuration/TrickplayOptions.cs
+++ b/MediaBrowser.Model/Configuration/TrickplayOptions.cs
@@ -19,6 +19,12 @@ public class TrickplayOptions
public bool EnableHwEncoding { get; set; } = false;
/// <summary>
+ /// Gets or sets a value indicating whether to only extract key frames.
+ /// Significantly faster, but is not compatible with all decoders and/or video files.
+ /// </summary>
+ public bool EnableKeyFrameOnlyExtraction { get; set; } = false;
+
+ /// <summary>
/// Gets or sets the behavior used by trickplay provider on library scan/update.
/// </summary>
public TrickplayScanBehavior ScanBehavior { get; set; } = TrickplayScanBehavior.NonBlocking;
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index 55d1c3d51..d37528ede 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -25,7 +25,7 @@ namespace MediaBrowser.Model.Dlna
private readonly ILogger _logger;
private readonly ITranscoderSupport _transcoderSupport;
- private static readonly string[] _supportedHlsVideoCodecs = new string[] { "h264", "hevc", "av1" };
+ private static readonly string[] _supportedHlsVideoCodecs = new string[] { "h264", "hevc", "vp9", "av1" };
private static readonly string[] _supportedHlsAudioCodecsTs = new string[] { "aac", "ac3", "eac3", "mp3" };
private static readonly string[] _supportedHlsAudioCodecsMp4 = new string[] { "aac", "ac3", "eac3", "mp3", "alac", "flac", "opus", "dca", "truehd" };
@@ -108,7 +108,7 @@ namespace MediaBrowser.Model.Dlna
var inputAudioSampleRate = audioStream?.SampleRate;
var inputAudioBitDepth = audioStream?.BitDepth;
- if (directPlayMethod.HasValue)
+ if (directPlayMethod is PlayMethod.DirectPlay)
{
var profile = options.Profile;
var audioFailureConditions = GetProfileConditionsForAudio(profile.CodecProfiles, item.Container, audioStream?.Codec, inputAudioChannels, inputAudioBitrate, inputAudioSampleRate, inputAudioBitDepth, true);
@@ -124,6 +124,46 @@ namespace MediaBrowser.Model.Dlna
}
}
+ if (directPlayMethod is PlayMethod.DirectStream)
+ {
+ var remuxContainer = item.TranscodingContainer ?? "ts";
+ var supportedHlsContainers = new[] { "ts", "mp4" };
+ // If the container specified for the profile is an HLS supported container, use that container instead, overriding the preference
+ // The client should be responsible to ensure this container is compatible
+ remuxContainer = Array.Exists(supportedHlsContainers, element => string.Equals(element, directPlayInfo.Profile?.Container, StringComparison.OrdinalIgnoreCase)) ? directPlayInfo.Profile?.Container : remuxContainer;
+ bool codeIsSupported;
+ if (item.TranscodingSubProtocol == MediaStreamProtocol.hls)
+ {
+ // Enforce HLS audio codec restrictions
+ if (string.Equals(remuxContainer, "mp4", StringComparison.OrdinalIgnoreCase))
+ {
+ codeIsSupported = _supportedHlsAudioCodecsMp4.Contains(directPlayInfo.Profile?.AudioCodec ?? directPlayInfo.Profile?.Container);
+ }
+ else
+ {
+ codeIsSupported = _supportedHlsAudioCodecsTs.Contains(directPlayInfo.Profile?.AudioCodec ?? directPlayInfo.Profile?.Container);
+ }
+ }
+ else
+ {
+ // Let's assume the client has given a correct container for http
+ codeIsSupported = true;
+ }
+
+ if (codeIsSupported)
+ {
+ playlistItem.PlayMethod = directPlayMethod.Value;
+ playlistItem.Container = remuxContainer;
+ playlistItem.TranscodeReasons = transcodeReasons;
+ playlistItem.SubProtocol = item.TranscodingSubProtocol;
+ item.TranscodingContainer = remuxContainer;
+ return playlistItem;
+ }
+
+ transcodeReasons |= TranscodeReason.AudioCodecNotSupported;
+ playlistItem.TranscodeReasons = transcodeReasons;
+ }
+
TranscodingProfile? transcodingProfile = null;
foreach (var tcProfile in options.Profile.TranscodingProfiles)
{
@@ -379,6 +419,7 @@ namespace MediaBrowser.Model.Dlna
var directPlayProfile = options.Profile.DirectPlayProfiles
.FirstOrDefault(x => x.Type == DlnaProfileType.Audio && IsAudioDirectPlaySupported(x, item, audioStream));
+ TranscodeReason transcodeReasons = 0;
if (directPlayProfile is null)
{
_logger.LogDebug(
@@ -387,14 +428,25 @@ namespace MediaBrowser.Model.Dlna
item.Path ?? "Unknown path",
audioStream.Codec ?? "Unknown codec");
- return (null, null, GetTranscodeReasonsFromDirectPlayProfile(item, null, audioStream, options.Profile.DirectPlayProfiles));
- }
+ var directStreamProfile = options.Profile.DirectPlayProfiles
+ .FirstOrDefault(x => x.Type == DlnaProfileType.Audio && IsAudioDirectStreamSupported(x, item, audioStream));
- TranscodeReason transcodeReasons = 0;
+ if (directStreamProfile is not null)
+ {
+ directPlayProfile = directStreamProfile;
+ transcodeReasons |= TranscodeReason.ContainerNotSupported;
+ }
+ else
+ {
+ return (null, null, GetTranscodeReasonsFromDirectPlayProfile(item, null, audioStream, options.Profile.DirectPlayProfiles));
+ }
+ }
// The profile describes what the device supports
// If device requirements are satisfied then allow both direct stream and direct play
- if (item.SupportsDirectPlay)
+ // Note: As of 10.10 codebase, SupportsDirectPlay is always true because the MediaSourceInfo initializes this key as true
+ // Need to check additionally for current transcode reasons
+ if (item.SupportsDirectPlay && transcodeReasons == 0)
{
if (!IsBitrateLimitExceeded(item, options.GetMaxBitrate(true) ?? 0))
{
@@ -414,7 +466,10 @@ namespace MediaBrowser.Model.Dlna
{
if (!IsBitrateLimitExceeded(item, options.GetMaxBitrate(true) ?? 0))
{
- if (options.EnableDirectStream)
+ // Note: as of 10.10 codebase, the options.EnableDirectStream is always false due to
+ // "direct-stream http streaming is currently broken"
+ // Don't check that option for audio as we always assume that is supported
+ if (transcodeReasons == TranscodeReason.ContainerNotSupported)
{
return (directPlayProfile, PlayMethod.DirectStream, transcodeReasons);
}
@@ -542,6 +597,7 @@ namespace MediaBrowser.Model.Dlna
playlistItem.EnableMpegtsM2TsMode = transcodingProfile.EnableMpegtsM2TsMode;
playlistItem.BreakOnNonKeyFrames = transcodingProfile.BreakOnNonKeyFrames;
+ playlistItem.EnableAudioVbrEncoding = transcodingProfile.EnableAudioVbrEncoding;
if (transcodingProfile.MinSegments > 0)
{
@@ -2129,5 +2185,24 @@ namespace MediaBrowser.Model.Dlna
return true;
}
+
+ private static bool IsAudioDirectStreamSupported(DirectPlayProfile profile, MediaSourceInfo item, MediaStream audioStream)
+ {
+ // Check container type, this should NOT be supported
+ // If the container is supported, the file should be directly played
+ if (!profile.SupportsContainer(item.Container))
+ {
+ // Check audio codec, we cannot use the SupportsAudioCodec here
+ // Because that one assumes empty container supports all codec, which is just useless
+ string? audioCodec = audioStream?.Codec;
+ if (string.Equals(profile.AudioCodec, audioCodec, StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(profile.Container, audioCodec, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
}
}
diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs
index 75e5b6d18..c8a341d41 100644
--- a/MediaBrowser.Model/Dlna/StreamInfo.cs
+++ b/MediaBrowser.Model/Dlna/StreamInfo.cs
@@ -108,6 +108,8 @@ namespace MediaBrowser.Model.Dlna
public string? MediaSourceId => MediaSource?.Id;
+ public bool EnableAudioVbrEncoding { get; set; }
+
public bool IsDirectStream => MediaSource?.VideoType is not (VideoType.Dvd or VideoType.BluRay)
&& PlayMethod is PlayMethod.DirectStream or PlayMethod.DirectPlay;
@@ -768,6 +770,8 @@ namespace MediaBrowser.Model.Dlna
}
list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
+
+ list.Add(new NameValuePair("EnableAudioVbrEncoding", item.EnableAudioVbrEncoding.ToString(CultureInfo.InvariantCulture).ToLowerInvariant()));
}
list.Add(new NameValuePair("Tag", item.MediaSource?.ETag ?? string.Empty));
diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
index 891448c66..a556799de 100644
--- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs
+++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs
@@ -70,6 +70,10 @@ namespace MediaBrowser.Model.Dlna
public ProfileCondition[] Conditions { get; set; }
+ [DefaultValue(true)]
+ [XmlAttribute("enableAudioVbrEncoding")]
+ public bool EnableAudioVbrEncoding { get; set; } = true;
+
public string[] GetAudioCodecs()
{
return ContainerProfile.SplitValue(AudioCodec);
diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs
index cfff717db..7e8949e1f 100644
--- a/MediaBrowser.Model/Dto/BaseItemDto.cs
+++ b/MediaBrowser.Model/Dto/BaseItemDto.cs
@@ -65,7 +65,7 @@ namespace MediaBrowser.Model.Dto
public DateTime? DateLastMediaAdded { get; set; }
- public string ExtraType { get; set; }
+ public ExtraType? ExtraType { get; set; }
public int? AirsBeforeSeasonNumber { get; set; }
@@ -782,10 +782,10 @@ namespace MediaBrowser.Model.Dto
public string TimerId { get; set; }
/// <summary>
- /// Gets or sets the LUFS value.
+ /// Gets or sets the gain required for audio normalization.
/// </summary>
- /// <value>The LUFS Value.</value>
- public float? LUFS { get; set; }
+ /// <value>The gain required for audio normalization.</value>
+ public float? NormalizationGain { get; set; }
/// <summary>
/// Gets or sets the current program.
diff --git a/MediaBrowser.Model/Entities/CollectionTypeOptions.cs b/MediaBrowser.Model/Entities/CollectionTypeOptions.cs
index e1894d84a..fc4cfdd66 100644
--- a/MediaBrowser.Model/Entities/CollectionTypeOptions.cs
+++ b/MediaBrowser.Model/Entities/CollectionTypeOptions.cs
@@ -1,16 +1,49 @@
-#pragma warning disable CS1591
+#pragma warning disable SA1300 // Lowercase required for backwards compat.
-namespace MediaBrowser.Model.Entities
+namespace MediaBrowser.Model.Entities;
+
+/// <summary>
+/// The collection type options.
+/// </summary>
+public enum CollectionTypeOptions
{
- public enum CollectionTypeOptions
- {
- Movies = 0,
- TvShows = 1,
- Music = 2,
- MusicVideos = 3,
- HomeVideos = 4,
- BoxSets = 5,
- Books = 6,
- Mixed = 7
- }
+ /// <summary>
+ /// Movies.
+ /// </summary>
+ movies = 0,
+
+ /// <summary>
+ /// TV Shows.
+ /// </summary>
+ tvshows = 1,
+
+ /// <summary>
+ /// Music.
+ /// </summary>
+ music = 2,
+
+ /// <summary>
+ /// Music Videos.
+ /// </summary>
+ musicvideos = 3,
+
+ /// <summary>
+ /// Home Videos (and Photos).
+ /// </summary>
+ homevideos = 4,
+
+ /// <summary>
+ /// Box Sets.
+ /// </summary>
+ boxsets = 5,
+
+ /// <summary>
+ /// Books.
+ /// </summary>
+ books = 6,
+
+ /// <summary>
+ /// Mixed Movies and TV Shows.
+ /// </summary>
+ mixed = 7
}
diff --git a/MediaBrowser.Model/Entities/IHasShares.cs b/MediaBrowser.Model/Entities/IHasShares.cs
index b34d1a037..8c4ba6c42 100644
--- a/MediaBrowser.Model/Entities/IHasShares.cs
+++ b/MediaBrowser.Model/Entities/IHasShares.cs
@@ -1,4 +1,6 @@
-namespace MediaBrowser.Model.Entities;
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.Entities;
/// <summary>
/// Interface for access to shares.
@@ -8,5 +10,5 @@ public interface IHasShares
/// <summary>
/// Gets or sets the shares.
/// </summary>
- Share[] Shares { get; set; }
+ IReadOnlyList<PlaylistUserPermissions> Shares { get; set; }
}
diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs
index fa09902e5..844214fae 100644
--- a/MediaBrowser.Model/Entities/MediaStream.cs
+++ b/MediaBrowser.Model/Entities/MediaStream.cs
@@ -7,6 +7,7 @@ using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Text;
+using System.Text.Json.Serialization;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Model.Dlna;
@@ -273,13 +274,13 @@ namespace MediaBrowser.Model.Entities
attributes.Add(StringHelper.FirstToUpper(fullLanguage ?? Language));
}
- if (!string.IsNullOrEmpty(Codec) && !string.Equals(Codec, "dca", StringComparison.OrdinalIgnoreCase) && !string.Equals(Codec, "dts", StringComparison.OrdinalIgnoreCase))
+ if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase))
{
- attributes.Add(AudioCodec.GetFriendlyName(Codec));
+ attributes.Add(Profile);
}
- else if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase))
+ else if (!string.IsNullOrEmpty(Codec))
{
- attributes.Add(Profile);
+ attributes.Add(AudioCodec.GetFriendlyName(Codec));
}
if (!string.IsNullOrEmpty(ChannelLayout))
@@ -591,6 +592,33 @@ namespace MediaBrowser.Model.Entities
}
}
+ [JsonIgnore]
+ public bool IsPgsSubtitleStream
+ {
+ get
+ {
+ if (Type != MediaStreamType.Subtitle)
+ {
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(Codec) && !IsExternal)
+ {
+ return false;
+ }
+
+ return IsPgsFormat(Codec);
+ }
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether this is a subtitle steam that is extractable by ffmpeg.
+ /// All text-based and pgs subtitles can be extracted.
+ /// </summary>
+ /// <value><c>true</c> if this is a extractable subtitle steam otherwise, <c>false</c>.</value>
+ [JsonIgnore]
+ public bool IsExtractableSubtitleStream => IsTextSubtitleStream || IsPgsSubtitleStream;
+
/// <summary>
/// Gets or sets a value indicating whether [supports external stream].
/// </summary>
@@ -662,14 +690,22 @@ namespace MediaBrowser.Model.Entities
{
string codec = format ?? string.Empty;
- // sub = external .sub file
+ // microdvd and dvdsub/vobsub share the ".sub" file extension, but it's text-based.
+
+ return codec.Contains("microdvd", StringComparison.OrdinalIgnoreCase)
+ || (!codec.Contains("pgs", StringComparison.OrdinalIgnoreCase)
+ && !codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase)
+ && !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase));
+ }
+
+ public static bool IsPgsFormat(string format)
+ {
+ string codec = format ?? string.Empty;
- return !codec.Contains("pgs", StringComparison.OrdinalIgnoreCase)
- && !codec.Contains("dvd", StringComparison.OrdinalIgnoreCase)
- && !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase);
+ return codec.Contains("pgs", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase);
}
public bool SupportsSubtitleConversionTo(string toCodec)
@@ -736,6 +772,8 @@ namespace MediaBrowser.Model.Entities
1 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10),
4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG),
2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR),
+ // While not in Dolby Spec, Profile 8 CCid 6 media are possible to create, and since CCid 6 stems from Bluray (Profile 7 originally) an HDR10 base layer is guaranteed to exist.
+ 6 => (VideoRange.HDR, VideoRangeType.DOVIWithHDR10),
// There is no other case to handle here as per Dolby Spec. Default case included for completeness and linting purposes
_ => (VideoRange.SDR, VideoRangeType.SDR)
},
diff --git a/MediaBrowser.Model/Entities/PlaylistUserPermissions.cs b/MediaBrowser.Model/Entities/PlaylistUserPermissions.cs
new file mode 100644
index 000000000..b5f017d2b
--- /dev/null
+++ b/MediaBrowser.Model/Entities/PlaylistUserPermissions.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace MediaBrowser.Model.Entities;
+
+/// <summary>
+/// Class to hold data on user permissions for playlists.
+/// </summary>
+public class PlaylistUserPermissions
+{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlaylistUserPermissions"/> class.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="canEdit">Edit permission.</param>
+ public PlaylistUserPermissions(Guid userId, bool canEdit = false)
+ {
+ UserId = userId;
+ CanEdit = canEdit;
+ }
+
+ /// <summary>
+ /// Gets or sets the user id.
+ /// </summary>
+ public Guid UserId { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the user has edit permissions.
+ /// </summary>
+ public bool CanEdit { get; set; }
+}
diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
index cf453d62c..1c73091f0 100644
--- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
+++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs
@@ -111,31 +111,32 @@ namespace MediaBrowser.Model.Entities
/// Sets a provider id.
/// </summary>
/// <param name="instance">The instance.</param>
- /// <param name="name">The name.</param>
+ /// <param name="name">The name, this should not contain a '=' character.</param>
/// <param name="value">The value.</param>
- public static void SetProviderId(this IHasProviderIds instance, string name, string? value)
+ /// <remarks>Due to how deserialization from the database works the name can not contain '='.</remarks>
+ public static void SetProviderId(this IHasProviderIds instance, string name, string value)
{
ArgumentNullException.ThrowIfNull(instance);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentException.ThrowIfNullOrEmpty(value);
+
+ // When name contains a '=' it can't be deserialized from the database
+ if (name.Contains('=', StringComparison.Ordinal))
+ {
+ throw new ArgumentException("Provider id name cannot contain '='", nameof(name));
+ }
+
+ // Ensure it exists
+ instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
- // If it's null remove the key from the dictionary
- if (string.IsNullOrEmpty(value))
+ // Match on internal MetadataProvider enum string values before adding arbitrary providers
+ if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue))
{
- instance.ProviderIds?.Remove(name);
+ instance.ProviderIds[enumValue] = value;
}
else
{
- // Ensure it exists
- instance.ProviderIds ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
-
- // Match on internal MetadataProvider enum string values before adding arbitrary providers
- if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue))
- {
- instance.ProviderIds[enumValue] = value;
- }
- else
- {
- instance.ProviderIds[name] = value;
- }
+ instance.ProviderIds[name] = value;
}
}
@@ -149,5 +150,30 @@ namespace MediaBrowser.Model.Entities
{
instance.SetProviderId(provider.ToString(), value);
}
+
+ /// <summary>
+ /// Removes a provider id.
+ /// </summary>
+ /// <param name="instance">The instance.</param>
+ /// <param name="name">The name.</param>
+ public static void RemoveProviderId(this IHasProviderIds instance, string name)
+ {
+ ArgumentNullException.ThrowIfNull(instance);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ instance.ProviderIds?.Remove(name);
+ }
+
+ /// <summary>
+ /// Removes a provider id.
+ /// </summary>
+ /// <param name="instance">The instance.</param>
+ /// <param name="provider">The provider.</param>
+ public static void RemoveProviderId(this IHasProviderIds instance, MetadataProvider provider)
+ {
+ ArgumentNullException.ThrowIfNull(instance);
+
+ instance.ProviderIds?.Remove(provider.ToString());
+ }
}
}
diff --git a/MediaBrowser.Model/Entities/Share.cs b/MediaBrowser.Model/Entities/Share.cs
deleted file mode 100644
index 186aad189..000000000
--- a/MediaBrowser.Model/Entities/Share.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace MediaBrowser.Model.Entities;
-
-/// <summary>
-/// Class to hold data on sharing permissions.
-/// </summary>
-public class Share
-{
- /// <summary>
- /// Gets or sets the user id.
- /// </summary>
- public string? UserId { get; set; }
-
- /// <summary>
- /// Gets or sets a value indicating whether the user has edit permissions.
- /// </summary>
- public bool CanEdit { get; set; }
-}
diff --git a/MediaBrowser.Model/Entities/VirtualFolderInfo.cs b/MediaBrowser.Model/Entities/VirtualFolderInfo.cs
index 2b2bda12c..ea3df3726 100644
--- a/MediaBrowser.Model/Entities/VirtualFolderInfo.cs
+++ b/MediaBrowser.Model/Entities/VirtualFolderInfo.cs
@@ -2,8 +2,6 @@
#pragma warning disable CS1591
using System;
-using System.Text.Json.Serialization;
-using Jellyfin.Extensions.Json.Converters;
using MediaBrowser.Model.Configuration;
namespace MediaBrowser.Model.Entities
@@ -37,7 +35,6 @@ namespace MediaBrowser.Model.Entities
/// Gets or sets the type of the collection.
/// </summary>
/// <value>The type of the collection.</value>
- [JsonConverter(typeof(JsonLowerCaseConverter<CollectionTypeOptions?>))]
public CollectionTypeOptions? CollectionType { get; set; }
public LibraryOptions LibraryOptions { get; set; }
diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj
index 7af46f8a0..b4a30b5d5 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.9.0</VersionPrefix>
+ <VersionPrefix>10.10.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
@@ -33,7 +33,10 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.AspNetCore.HttpOverrides" />
+ <FrameworkReference Include="Microsoft.AspNetCore.App" />
+ </ItemGroup>
+
+ <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="MimeTypes">
<PrivateAssets>all</PrivateAssets>
diff --git a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
index 62d496d04..ec54b1afd 100644
--- a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
+++ b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
@@ -18,7 +18,7 @@ public class PlaylistCreationRequest
/// <summary>
/// Gets or sets the list of items.
/// </summary>
- public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>();
+ public IReadOnlyList<Guid> ItemIdList { get; set; } = [];
/// <summary>
/// Gets or sets the media type.
@@ -31,7 +31,12 @@ public class PlaylistCreationRequest
public Guid UserId { get; set; }
/// <summary>
- /// Gets or sets the shares.
+ /// Gets or sets the user permissions.
/// </summary>
- public Share[]? Shares { get; set; }
+ public IReadOnlyList<PlaylistUserPermissions> Users { get; set; } = [];
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the playlist is public.
+ /// </summary>
+ public bool? Public { get; set; } = true;
}
diff --git a/MediaBrowser.Model/Playlists/PlaylistUpdateRequest.cs b/MediaBrowser.Model/Playlists/PlaylistUpdateRequest.cs
new file mode 100644
index 000000000..db290bbdb
--- /dev/null
+++ b/MediaBrowser.Model/Playlists/PlaylistUpdateRequest.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Model.Entities;
+
+namespace MediaBrowser.Model.Playlists;
+
+/// <summary>
+/// A playlist update request.
+/// </summary>
+public class PlaylistUpdateRequest
+{
+ /// <summary>
+ /// Gets or sets the id of the playlist.
+ /// </summary>
+ public Guid Id { get; set; }
+
+ /// <summary>
+ /// Gets or sets the id of the user updating the playlist.
+ /// </summary>
+ public Guid UserId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the name of the playlist.
+ /// </summary>
+ public string? Name { get; set; }
+
+ /// <summary>
+ /// Gets or sets item ids to add to the playlist.
+ /// </summary>
+ public IReadOnlyList<Guid>? Ids { get; set; }
+
+ /// <summary>
+ /// Gets or sets the playlist users.
+ /// </summary>
+ public IReadOnlyList<PlaylistUserPermissions>? Users { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the playlist is public.
+ /// </summary>
+ public bool? Public { get; set; }
+}
diff --git a/MediaBrowser.Model/Playlists/PlaylistUserUpdateRequest.cs b/MediaBrowser.Model/Playlists/PlaylistUserUpdateRequest.cs
new file mode 100644
index 000000000..1840efdf3
--- /dev/null
+++ b/MediaBrowser.Model/Playlists/PlaylistUserUpdateRequest.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace MediaBrowser.Model.Playlists;
+
+/// <summary>
+/// A playlist user update request.
+/// </summary>
+public class PlaylistUserUpdateRequest
+{
+ /// <summary>
+ /// Gets or sets the id of the playlist.
+ /// </summary>
+ public Guid Id { get; set; }
+
+ /// <summary>
+ /// Gets or sets the id of the updated user.
+ /// </summary>
+ public Guid UserId { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the user can edit the playlist.
+ /// </summary>
+ public bool? CanEdit { get; set; }
+}
diff --git a/MediaBrowser.Model/Providers/ExternalIdInfo.cs b/MediaBrowser.Model/Providers/ExternalIdInfo.cs
index d026d574f..1f5163aa8 100644
--- a/MediaBrowser.Model/Providers/ExternalIdInfo.cs
+++ b/MediaBrowser.Model/Providers/ExternalIdInfo.cs
@@ -1,3 +1,5 @@
+using System;
+
namespace MediaBrowser.Model.Providers
{
/// <summary>
@@ -17,7 +19,9 @@ namespace MediaBrowser.Model.Providers
Name = name;
Key = key;
Type = type;
+#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11
UrlFormatString = urlFormatString;
+#pragma warning restore CS0618 // Type or member is obsolete
}
/// <summary>
@@ -46,6 +50,7 @@ namespace MediaBrowser.Model.Providers
/// <summary>
/// Gets or sets the URL format string.
/// </summary>
+ [Obsolete("Obsolete in 10.10, to be removed in 10.11")]
public string? UrlFormatString { get; set; }
}
}
diff --git a/MediaBrowser.Model/Search/SearchHint.cs b/MediaBrowser.Model/Search/SearchHint.cs
index fd911dbed..2e2979fcf 100644
--- a/MediaBrowser.Model/Search/SearchHint.cs
+++ b/MediaBrowser.Model/Search/SearchHint.cs
@@ -43,7 +43,7 @@ namespace MediaBrowser.Model.Search
/// Gets or sets the matched term.
/// </summary>
/// <value>The matched term.</value>
- public string MatchedTerm { get; set; }
+ public string? MatchedTerm { get; set; }
/// <summary>
/// Gets or sets the index number.
diff --git a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs
index f8a8c727e..1d8767dc1 100644
--- a/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs
+++ b/MediaBrowser.Model/Tasks/TaskTriggerInfo.cs
@@ -13,7 +13,6 @@ namespace MediaBrowser.Model.Tasks
public const string TriggerDaily = "DailyTrigger";
public const string TriggerWeekly = "WeeklyTrigger";
public const string TriggerInterval = "IntervalTrigger";
- public const string TriggerSystemEvent = "SystemEventTrigger";
public const string TriggerStartup = "StartupTrigger";
/// <summary>
diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs
index d82716831..9a676cb2e 100644
--- a/MediaBrowser.Providers/Manager/ImageSaver.cs
+++ b/MediaBrowser.Providers/Manager/ImageSaver.cs
@@ -14,6 +14,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
@@ -100,8 +101,8 @@ namespace MediaBrowser.Providers.Manager
{
saveLocally = false;
- // If season is virtual under a physical series, save locally if using compatible convention
- if (item is Season season && _config.Configuration.ImageSavingConvention == ImageSavingConvention.Compatible)
+ // If season is virtual under a physical series, save locally
+ if (item is Season season)
{
var series = season.Series;
@@ -126,7 +127,11 @@ namespace MediaBrowser.Providers.Manager
var paths = GetSavePaths(item, type, imageIndex, mimeType, saveLocally);
- var retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false);
+ string[] retryPaths = [];
+ if (saveLocally)
+ {
+ retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false);
+ }
// If there are more than one output paths, the stream will need to be seekable
if (paths.Length > 1 && !source.CanSeek)
@@ -183,6 +188,29 @@ namespace MediaBrowser.Providers.Manager
try
{
_fileSystem.DeleteFile(currentPath);
+
+ // Remove local episode metadata directory if it exists and is empty
+ var directory = Path.GetDirectoryName(currentPath);
+ if (item is Episode && directory.Equals("metadata", StringComparison.Ordinal))
+ {
+ var parentDirectoryPath = Directory.GetParent(currentPath).FullName;
+ if (_fileSystem.DirectoryExists(parentDirectoryPath) && !_fileSystem.GetFiles(parentDirectoryPath).Any())
+ {
+ try
+ {
+ _logger.LogInformation("Deleting empty local metadata folder {Folder}", parentDirectoryPath);
+ Directory.Delete(parentDirectoryPath);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ _logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath);
+ }
+ catch (IOException ex)
+ {
+ _logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath);
+ }
+ }
+ }
}
catch (FileNotFoundException)
{
@@ -374,6 +402,47 @@ namespace MediaBrowser.Providers.Manager
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Unable to determine image file extension from mime type {0}", mimeType));
}
+ if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase))
+ {
+ extension = ".jpg";
+ }
+
+ extension = extension.ToLowerInvariant();
+
+ if (type == ImageType.Primary && 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 + "-poster" + extension;
+
+ return Path.Combine(seriesFolder, imageFilename);
+ }
+ }
+
+ if (type == ImageType.Backdrop && saveLocally)
+ {
+ if (season is not null
+ && season.IndexNumber.HasValue
+ && (imageIndex is null || imageIndex == 0))
+ {
+ var seriesFolder = season.SeriesPath;
+
+ var seasonMarker = season.IndexNumber.Value == 0
+ ? "-specials"
+ : season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
+
+ var imageFilename = "season" + seasonMarker + "-fanart" + extension;
+
+ return Path.Combine(seriesFolder, imageFilename);
+ }
+ }
+
if (type == ImageType.Thumb && saveLocally)
{
if (season is not null && season.IndexNumber.HasValue)
@@ -447,20 +516,12 @@ namespace MediaBrowser.Providers.Manager
break;
}
- if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase))
- {
- extension = ".jpg";
- }
-
- extension = extension.ToLowerInvariant();
-
string path = null;
-
if (saveLocally)
{
if (type == ImageType.Primary && item is Episode)
{
- path = Path.Combine(Path.GetDirectoryName(item.Path), "metadata", filename + extension);
+ path = Path.Combine(Path.GetDirectoryName(item.Path), filename + "-thumb" + extension);
}
else if (item.IsInMixedFolder)
{
diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
index 1a5dbd7a5..1bb7ffcce 100644
--- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs
+++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
@@ -10,6 +10,7 @@ using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers;
@@ -96,7 +97,7 @@ namespace MediaBrowser.Providers.Manager
public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, ImageRefreshOptions refreshOptions)
{
var hasChanges = false;
- IDirectoryService directoryService = refreshOptions?.DirectoryService;
+ var directoryService = refreshOptions?.DirectoryService;
if (item is not Photo)
{
@@ -158,7 +159,7 @@ namespace MediaBrowser.Providers.Manager
}
}
- // only delete existing multi-images if new ones were added
+ // Only delete existing multi-images if new ones were added
if (oldBackdropImages.Length > 0 && oldBackdropImages.Length < item.GetImages(ImageType.Backdrop).Count())
{
PruneImages(item, oldBackdropImages);
@@ -359,10 +360,8 @@ namespace MediaBrowser.Providers.Manager
private void PruneImages(BaseItem item, IReadOnlyList<ItemImageInfo> images)
{
- for (var i = 0; i < images.Count; i++)
+ foreach (var image in images)
{
- var image = images[i];
-
if (image.IsLocalFile)
{
try
@@ -371,7 +370,7 @@ namespace MediaBrowser.Providers.Manager
}
catch (FileNotFoundException)
{
- // nothing to do, already gone
+ // Nothing to do, already gone
}
catch (UnauthorizedAccessException ex)
{
@@ -381,6 +380,16 @@ namespace MediaBrowser.Providers.Manager
}
item.RemoveImages(images);
+
+ // Cleanup old metadata directory for episodes if empty
+ if (item is Episode)
+ {
+ var oldLocalMetadataDirectory = Path.Combine(item.ContainingFolderPath, "metadata");
+ if (_fileSystem.DirectoryExists(oldLocalMetadataDirectory) && !_fileSystem.GetFiles(oldLocalMetadataDirectory).Any())
+ {
+ Directory.Delete(oldLocalMetadataDirectory);
+ }
+ }
}
/// <summary>
@@ -413,12 +422,10 @@ namespace MediaBrowser.Providers.Manager
{
var changed = item.ValidateImages();
var foundImageTypes = new List<ImageType>();
-
for (var i = 0; i < _singularImages.Length; i++)
{
var type = _singularImages[i];
var image = GetFirstLocalImageInfoByType(images, type);
-
if (image is not null)
{
var currentImage = item.GetImageInfo(type, 0);
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index 6f473fc07..8af4ed2a8 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -8,7 +8,6 @@ using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
-using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@@ -72,7 +71,7 @@ namespace MediaBrowser.Providers.Manager
}
}
- public async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
+ public virtual async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
{
var itemOfType = (TItemType)item;
@@ -93,10 +92,6 @@ namespace MediaBrowser.Providers.Manager
}
}
- var localImagesFailed = false;
-
- var allImageProviders = ProviderManager.GetImageProviders(item, refreshOptions).ToList();
-
if (refreshOptions.RemoveOldMetadata && refreshOptions.ReplaceAllImages)
{
if (ImageProvider.RemoveImages(item))
@@ -105,24 +100,35 @@ namespace MediaBrowser.Providers.Manager
}
}
- // Start by validating images
- try
+ var localImagesFailed = false;
+ var allImageProviders = ProviderManager.GetImageProviders(item, refreshOptions).ToList();
+
+ // Only validate already registered images if we are replacing and saving locally
+ if (item.IsSaveLocalMetadataEnabled() && refreshOptions.ReplaceAllImages)
{
- // Always validate images and check for new locally stored ones.
- if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions))
- {
- updateType |= ItemUpdateType.ImageUpdate;
- }
+ item.ValidateImages();
}
- catch (Exception ex)
+ else
{
- localImagesFailed = true;
- Logger.LogError(ex, "Error validating images for {Item}", item.Path ?? item.Name ?? "Unknown name");
+ // Run full image validation and register new local images
+ try
+ {
+ if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions))
+ {
+ updateType |= ItemUpdateType.ImageUpdate;
+ }
+ }
+ catch (Exception ex)
+ {
+ localImagesFailed = true;
+ Logger.LogError(ex, "Error validating images for {Item}", item.Path ?? item.Name ?? "Unknown name");
+ }
}
var metadataResult = new MetadataResult<TItemType>
{
- Item = itemOfType
+ Item = itemOfType,
+ People = LibraryManager.GetPeople(item)
};
bool hasRefreshedMetadata = true;
@@ -154,7 +160,8 @@ namespace MediaBrowser.Providers.Manager
id.IsAutomated = refreshOptions.IsAutomated;
- var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, cancellationToken).ConfigureAwait(false);
+ var hasMetadataSavers = ProviderManager.GetMetadataSavers(item, libraryOptions).Any();
+ var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, hasMetadataSavers, cancellationToken).ConfigureAwait(false);
updateType |= result.UpdateType;
if (result.Failures > 0)
@@ -165,7 +172,7 @@ namespace MediaBrowser.Providers.Manager
}
// Next run remote image providers, but only if local image providers didn't throw an exception
- if (!localImagesFailed && refreshOptions.ImageRefreshMode != MetadataRefreshMode.ValidationOnly)
+ if (!localImagesFailed && refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly)
{
var providers = GetNonLocalImageProviders(item, allImageProviders, refreshOptions).ToList();
@@ -243,7 +250,7 @@ namespace MediaBrowser.Providers.Manager
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken)
{
- if (result.Item.SupportsPeople && result.People is not null)
+ if (result.Item.SupportsPeople)
{
var baseItem = result.Item;
@@ -399,7 +406,8 @@ namespace MediaBrowser.Providers.Manager
foreach (var child in children)
{
- if (!child.IsFolder)
+ // Exclude any folders and virtual items since they are only placeholders
+ if (!child.IsFolder && !child.IsVirtualItem)
{
var childDateCreated = child.DateCreated;
if (childDateCreated > dateLastMediaAdded)
@@ -638,6 +646,7 @@ namespace MediaBrowser.Providers.Manager
MetadataRefreshOptions options,
ICollection<IMetadataProvider> providers,
ItemImageProvider imageService,
+ bool isSavingMetadata,
CancellationToken cancellationToken)
{
var refreshResult = new RefreshResult
@@ -655,102 +664,96 @@ namespace MediaBrowser.Providers.Manager
await RunCustomProvider(provider, item, logName, options, refreshResult, cancellationToken).ConfigureAwait(false);
}
+ if (item.IsLocked)
+ {
+ return refreshResult;
+ }
+
var temp = new MetadataResult<TItemType>
{
Item = CreateNew()
};
temp.Item.Path = item.Path;
+ temp.Item.Id = item.Id;
+ temp.Item.PreferredMetadataCountryCode = item.PreferredMetadataCountryCode;
+ temp.Item.PreferredMetadataLanguage = item.PreferredMetadataLanguage;
- // If replacing all metadata, run internet providers first
- if (options.ReplaceAllMetadata)
- {
- var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
- .ConfigureAwait(false);
-
- refreshResult.UpdateType |= remoteResult.UpdateType;
- refreshResult.ErrorMessage = remoteResult.ErrorMessage;
- refreshResult.Failures += remoteResult.Failures;
- }
-
- var hasLocalMetadata = false;
var foundImageTypes = new List<ImageType>();
- foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>())
+ // Do not execute local providers if we are identifying or replacing with local metadata saving enabled
+ if (options.SearchResult is null && !(isSavingMetadata && options.ReplaceAllMetadata))
{
- var providerName = provider.GetType().Name;
- Logger.LogDebug("Running {Provider} for {Item}", providerName, logName);
-
- var itemInfo = new ItemInfo(item);
-
- try
+ foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>())
{
- var localItem = await provider.GetMetadata(itemInfo, options.DirectoryService, cancellationToken).ConfigureAwait(false);
+ var providerName = provider.GetType().Name;
+ Logger.LogDebug("Running {Provider} for {Item}", providerName, logName);
+
+ var itemInfo = new ItemInfo(item);
- if (localItem.HasMetadata)
+ try
{
- foreach (var remoteImage in localItem.RemoteImages)
+ var localItem = await provider.GetMetadata(itemInfo, options.DirectoryService, cancellationToken).ConfigureAwait(false);
+
+ if (localItem.HasMetadata)
{
- try
+ foreach (var remoteImage in localItem.RemoteImages)
{
- if (item.ImageInfos.Any(x => x.Type == remoteImage.Type)
- && !options.IsReplacingImage(remoteImage.Type))
+ try
{
- continue;
- }
+ if (item.ImageInfos.Any(x => x.Type == remoteImage.Type)
+ && !options.IsReplacingImage(remoteImage.Type))
+ {
+ continue;
+ }
- await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false);
- refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
+ await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false);
+ refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
- // remember imagetype that has just been downloaded
- foundImageTypes.Add(remoteImage.Type);
+ // remember imagetype that has just been downloaded
+ foundImageTypes.Add(remoteImage.Type);
+ }
+ catch (HttpRequestException ex)
+ {
+ Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.Type), remoteImage.Url);
+ }
}
- catch (HttpRequestException ex)
+
+ if (foundImageTypes.Count > 0)
{
- Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.Type), remoteImage.Url);
+ imageService.UpdateReplaceImages(options, foundImageTypes);
}
- }
- if (foundImageTypes.Count > 0)
- {
- imageService.UpdateReplaceImages(options, foundImageTypes);
- }
-
- if (imageService.MergeImages(item, localItem.Images, options))
- {
- refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
- }
+ if (imageService.MergeImages(item, localItem.Images, options))
+ {
+ refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
+ }
- MergeData(localItem, temp, Array.Empty<MetadataField>(), options.ReplaceAllMetadata, true);
- refreshResult.UpdateType |= ItemUpdateType.MetadataImport;
+ MergeData(localItem, temp, Array.Empty<MetadataField>(), false, true);
+ refreshResult.UpdateType |= ItemUpdateType.MetadataImport;
- // Only one local provider allowed per item
- if (item.IsLocked || localItem.Item.IsLocked || IsFullLocalMetadata(localItem.Item))
- {
- hasLocalMetadata = true;
+ break;
}
- break;
+ Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName);
}
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error in {Provider}", provider.Name);
- Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName);
- }
- catch (OperationCanceledException)
- {
- throw;
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error in {Provider}", provider.Name);
-
- // If a local provider fails, consider that a failure
- refreshResult.ErrorMessage = ex.Message;
+ // If a local provider fails, consider that a failure
+ refreshResult.ErrorMessage = ex.Message;
+ }
}
}
- // Local metadata is king - if any is found don't run remote providers
- if (!options.ReplaceAllMetadata && (!hasLocalMetadata || options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || !item.StopRefreshIfLocalMetadataFound))
+ var isLocalLocked = temp.Item.IsLocked;
+ if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly))
{
- var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
+ var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
.ConfigureAwait(false);
refreshResult.UpdateType |= remoteResult.UpdateType;
@@ -762,19 +765,20 @@ namespace MediaBrowser.Providers.Manager
{
if (refreshResult.UpdateType > ItemUpdateType.None)
{
- if (hasLocalMetadata)
+ if (!options.RemoveOldMetadata)
+ {
+ // Add existing metadata to provider result if it does not exist there
+ MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false);
+ }
+
+ if (isLocalLocked)
{
MergeData(temp, metadata, item.LockedFields, true, true);
}
else
{
- if (!options.RemoveOldMetadata)
- {
- MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false);
- }
-
- // Will always replace all metadata when Scan for new and updated files is used. Else, follow the options.
- MergeData(temp, metadata, item.LockedFields, options.MetadataRefreshMode == MetadataRefreshMode.Default || options.ReplaceAllMetadata, false);
+ var shouldReplace = options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly || options.ReplaceAllMetadata;
+ MergeData(temp, metadata, item.LockedFields, shouldReplace, true);
}
}
}
@@ -787,16 +791,6 @@ namespace MediaBrowser.Providers.Manager
return refreshResult;
}
- protected virtual bool IsFullLocalMetadata(TItemType item)
- {
- if (string.IsNullOrWhiteSpace(item.Name))
- {
- return false;
- }
-
- return true;
- }
-
private async Task RunCustomProvider(ICustomMetadataProvider<TItemType> provider, TItemType item, string logName, MetadataRefreshOptions options, RefreshResult refreshResult, CancellationToken cancellationToken)
{
Logger.LogDebug("Running {Provider} for {Item}", provider.GetType().Name, logName);
@@ -821,23 +815,20 @@ namespace MediaBrowser.Providers.Manager
return new TItemType();
}
- private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken)
+ private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, bool replaceData, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken)
{
var refreshResult = new RefreshResult();
- var tmpDataMerged = false;
+ if (id is not null)
+ {
+ MergeNewData(temp.Item, id);
+ }
foreach (var provider in providers)
{
var providerName = provider.GetType().Name;
Logger.LogDebug("Running {Provider} for {Item}", providerName, logName);
- if (id is not null && !tmpDataMerged)
- {
- MergeNewData(temp.Item, id);
- tmpDataMerged = true;
- }
-
try
{
var result = await provider.GetMetadata(id, cancellationToken).ConfigureAwait(false);
@@ -846,7 +837,7 @@ namespace MediaBrowser.Providers.Manager
{
result.Provider = provider.Name;
- MergeData(result, temp, Array.Empty<MetadataField>(), false, false);
+ MergeData(result, temp, Array.Empty<MetadataField>(), replaceData, false);
MergeNewData(temp.Item, id);
refreshResult.UpdateType |= ItemUpdateType.MetadataDownload;
@@ -949,11 +940,7 @@ namespace MediaBrowser.Providers.Manager
if (replaceData || string.IsNullOrEmpty(target.OriginalTitle))
{
- // Safeguard against incoming data having an empty name
- if (!string.IsNullOrWhiteSpace(source.OriginalTitle))
- {
- target.OriginalTitle = source.OriginalTitle;
- }
+ target.OriginalTitle = source.OriginalTitle;
}
if (replaceData || !target.CommunityRating.HasValue)
@@ -1016,7 +1003,7 @@ namespace MediaBrowser.Providers.Manager
{
targetResult.People = sourceResult.People;
}
- else if (targetResult.People is not null && sourceResult.People is not null)
+ else if (sourceResult.People is not null && sourceResult.People.Count > 0)
{
MergePeople(sourceResult.People, targetResult.People);
}
@@ -1049,6 +1036,10 @@ namespace MediaBrowser.Providers.Manager
{
target.Studios = source.Studios;
}
+ else
+ {
+ target.Studios = target.Studios.Concat(source.Studios).Distinct().ToArray();
+ }
}
if (!lockedFields.Contains(MetadataField.Tags))
@@ -1057,6 +1048,10 @@ namespace MediaBrowser.Providers.Manager
{
target.Tags = source.Tags;
}
+ else
+ {
+ target.Tags = target.Tags.Concat(source.Tags).Distinct().ToArray();
+ }
}
if (!lockedFields.Contains(MetadataField.ProductionLocations))
@@ -1065,6 +1060,10 @@ namespace MediaBrowser.Providers.Manager
{
target.ProductionLocations = source.ProductionLocations;
}
+ else
+ {
+ target.ProductionLocations = target.ProductionLocations.Concat(source.ProductionLocations).Distinct().ToArray();
+ }
}
foreach (var id in source.ProviderIds)
@@ -1082,17 +1081,28 @@ namespace MediaBrowser.Providers.Manager
}
}
+ if (replaceData || !target.CriticRating.HasValue)
+ {
+ target.CriticRating = source.CriticRating;
+ }
+
+ if (replaceData || target.RemoteTrailers.Count == 0)
+ {
+ target.RemoteTrailers = source.RemoteTrailers;
+ }
+ else
+ {
+ target.RemoteTrailers = target.RemoteTrailers.Concat(source.RemoteTrailers).DistinctBy(t => t.Url).ToArray();
+ }
+
MergeAlbumArtist(source, target, replaceData);
- MergeCriticRating(source, target, replaceData);
- MergeTrailers(source, target, replaceData);
MergeVideoInfo(source, target, replaceData);
MergeDisplayOrder(source, target, replaceData);
if (replaceData || string.IsNullOrEmpty(target.ForcedSortName))
{
var forcedSortName = source.ForcedSortName;
-
- if (!string.IsNullOrWhiteSpace(forcedSortName))
+ if (!string.IsNullOrEmpty(forcedSortName))
{
target.ForcedSortName = forcedSortName;
}
@@ -1100,22 +1110,44 @@ namespace MediaBrowser.Providers.Manager
if (mergeMetadataSettings)
{
- target.LockedFields = source.LockedFields;
- target.IsLocked = source.IsLocked;
+ if (replaceData || !target.IsLocked)
+ {
+ target.IsLocked = target.IsLocked || source.IsLocked;
+ }
+
+ if (target.LockedFields.Length == 0)
+ {
+ target.LockedFields = source.LockedFields;
+ }
+ else
+ {
+ target.LockedFields = target.LockedFields.Concat(source.LockedFields).Distinct().ToArray();
+ }
- // Grab the value if it's there, but if not then don't overwrite with the default
if (source.DateCreated != default)
{
target.DateCreated = source.DateCreated;
}
- target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode;
- target.PreferredMetadataLanguage = source.PreferredMetadataLanguage;
+ if (replaceData || string.IsNullOrEmpty(target.PreferredMetadataCountryCode))
+ {
+ target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode;
+ }
+
+ if (replaceData || string.IsNullOrEmpty(target.PreferredMetadataLanguage))
+ {
+ target.PreferredMetadataLanguage = source.PreferredMetadataLanguage;
+ }
}
}
private static void MergePeople(List<PersonInfo> source, List<PersonInfo> target)
{
+ if (target is null)
+ {
+ target = new List<PersonInfo>();
+ }
+
foreach (var person in target)
{
var normalizedName = person.Name.RemoveDiacritics();
@@ -1144,7 +1176,6 @@ namespace MediaBrowser.Providers.Manager
if (replaceData || string.IsNullOrEmpty(targetHasDisplayOrder.DisplayOrder))
{
var displayOrder = sourceHasDisplayOrder.DisplayOrder;
-
if (!string.IsNullOrWhiteSpace(displayOrder))
{
targetHasDisplayOrder.DisplayOrder = displayOrder;
@@ -1162,22 +1193,10 @@ namespace MediaBrowser.Providers.Manager
{
targetHasAlbumArtist.AlbumArtists = sourceHasAlbumArtist.AlbumArtists;
}
- }
- }
-
- private static void MergeCriticRating(BaseItem source, BaseItem target, bool replaceData)
- {
- if (replaceData || !target.CriticRating.HasValue)
- {
- target.CriticRating = source.CriticRating;
- }
- }
-
- private static void MergeTrailers(BaseItem source, BaseItem target, bool replaceData)
- {
- if (replaceData || target.RemoteTrailers.Count == 0)
- {
- target.RemoteTrailers = source.RemoteTrailers;
+ else if (sourceHasAlbumArtist.AlbumArtists.Count > 0)
+ {
+ targetHasAlbumArtist.AlbumArtists = targetHasAlbumArtist.AlbumArtists.Concat(sourceHasAlbumArtist.AlbumArtists).Distinct().ToArray();
+ }
}
}
@@ -1185,7 +1204,7 @@ namespace MediaBrowser.Providers.Manager
{
if (source is Video sourceCast && target is Video targetCast)
{
- if (replaceData || targetCast.Video3DFormat is null)
+ if (replaceData || !targetCast.Video3DFormat.HasValue)
{
targetCast.Video3DFormat = sourceCast.Video3DFormat;
}
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index f34034964..60d89a51b 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -69,11 +69,12 @@ namespace MediaBrowser.Providers.Manager
o.PoolInitialFill = 1;
});
- private IImageProvider[] _imageProviders = Array.Empty<IImageProvider>();
- private IMetadataService[] _metadataServices = Array.Empty<IMetadataService>();
- private IMetadataProvider[] _metadataProviders = Array.Empty<IMetadataProvider>();
- private IMetadataSaver[] _savers = Array.Empty<IMetadataSaver>();
- private IExternalId[] _externalIds = Array.Empty<IExternalId>();
+ private IImageProvider[] _imageProviders = [];
+ private IMetadataService[] _metadataServices = [];
+ private IMetadataProvider[] _metadataProviders = [];
+ private IMetadataSaver[] _savers = [];
+ private IExternalId[] _externalIds = [];
+ private IExternalUrlProvider[] _externalUrlProviders = [];
private bool _isProcessingRefreshQueue;
private bool _disposed;
@@ -132,12 +133,14 @@ namespace MediaBrowser.Providers.Manager
IEnumerable<IMetadataService> metadataServices,
IEnumerable<IMetadataProvider> metadataProviders,
IEnumerable<IMetadataSaver> metadataSavers,
- IEnumerable<IExternalId> externalIds)
+ IEnumerable<IExternalId> externalIds,
+ IEnumerable<IExternalUrlProvider> externalUrlProviders)
{
_imageProviders = imageProviders.ToArray();
_metadataServices = metadataServices.OrderBy(i => i.Order).ToArray();
_metadataProviders = metadataProviders.ToArray();
_externalIds = externalIds.OrderBy(i => i.ProviderName).ToArray();
+ _externalUrlProviders = externalUrlProviders.OrderBy(i => i.Name).ToArray();
_savers = metadataSavers.ToArray();
}
@@ -286,7 +289,7 @@ namespace MediaBrowser.Providers.Manager
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
- return results.SelectMany(i => i.ToList());
+ return results.SelectMany(i => i);
}
/// <summary>
@@ -418,6 +421,12 @@ namespace MediaBrowser.Providers.Manager
return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false);
}
+ /// <inheritdoc />
+ public IEnumerable<IMetadataSaver> GetMetadataSavers(BaseItem item, LibraryOptions libraryOptions)
+ {
+ return _savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, ItemUpdateType.MetadataEdit, false));
+ }
+
private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata)
where T : BaseItem
{
@@ -871,31 +880,35 @@ namespace MediaBrowser.Providers.Manager
/// <inheritdoc/>
public IEnumerable<ExternalUrl> GetExternalUrls(BaseItem item)
{
- return GetExternalIds(item)
+#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11
+ var legacyExternalIdUrls = GetExternalIds(item)
.Select(i =>
- {
- if (string.IsNullOrEmpty(i.UrlFormatString))
{
- return null;
- }
+ var urlFormatString = i.UrlFormatString;
+ if (string.IsNullOrEmpty(urlFormatString)
+ || !item.TryGetProviderId(i.Key, out var providerId))
+ {
+ return null;
+ }
- var value = item.GetProviderId(i.Key);
+ return new ExternalUrl
+ {
+ Name = i.ProviderName,
+ Url = string.Format(
+ CultureInfo.InvariantCulture,
+ urlFormatString,
+ providerId)
+ };
+ })
+ .OfType<ExternalUrl>();
+#pragma warning restore CS0618 // Type or member is obsolete
- if (string.IsNullOrEmpty(value))
- {
- return null;
- }
+ var externalUrls = _externalUrlProviders
+ .SelectMany(p => p
+ .GetExternalUrls(item)
+ .Select(externalUrl => new ExternalUrl { Name = p.Name, Url = externalUrl }));
- return new ExternalUrl
- {
- Name = i.ProviderName,
- Url = string.Format(
- CultureInfo.InvariantCulture,
- i.UrlFormatString,
- value)
- };
- }).Where(i => i is not null)
- .Concat(item.GetRelatedUrls())!; // We just filtered out all the nulls
+ return legacyExternalIdUrls.Concat(externalUrls).OrderBy(u => u.Name);
}
/// <inheritdoc/>
@@ -906,7 +919,9 @@ namespace MediaBrowser.Providers.Manager
name: i.ProviderName,
key: i.Key,
type: i.Type,
+#pragma warning disable CS0618 // Type or member is obsolete - Remove 10.11
urlFormatString: i.UrlFormatString));
+#pragma warning restore CS0618 // Type or member is obsolete
}
/// <inheritdoc/>
@@ -968,16 +983,13 @@ namespace MediaBrowser.Providers.Manager
var id = item.Id;
_logger.LogDebug("OnRefreshProgress {Id:N} {Progress}", id, progress);
- // TODO: Need to hunt down the conditions for this happening
- _activeRefreshes.AddOrUpdate(
- id,
- _ => throw new InvalidOperationException(
- string.Format(
- CultureInfo.InvariantCulture,
- "Cannot update refresh progress of item '{0}' ({1}) because a refresh for this item is not running",
- item.GetType().Name,
- item.Id.ToString("N", CultureInfo.InvariantCulture))),
- (_, _) => progress);
+ if (!_activeRefreshes.TryGetValue(id, out var current)
+ || progress <= current
+ || !_activeRefreshes.TryUpdate(id, progress, current))
+ {
+ // Item isn't currently refreshing, or update was received out-of-order, so don't trigger event.
+ return;
+ }
try
{
@@ -1106,9 +1118,10 @@ namespace MediaBrowser.Providers.Manager
var musicArtists = albums
.Select(i => i.MusicArtist)
- .Where(i => i is not null);
+ .Where(i => i is not null)
+ .Distinct();
- var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new Progress<double>(), options, true, cancellationToken));
+ var musicArtistRefreshTasks = musicArtists.Select(i => i.ValidateChildren(new Progress<double>(), options, true, false, cancellationToken));
await Task.WhenAll(musicArtistRefreshTasks).ConfigureAwait(false);
diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
index c9fe4c9b6..0083d4f75 100644
--- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
+++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs
@@ -1,12 +1,11 @@
using System;
using System.Collections.Generic;
-using System.Diagnostics;
using System.Globalization;
using System.Linq;
-using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
+using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
@@ -26,15 +25,12 @@ namespace MediaBrowser.Providers.MediaInfo
/// <summary>
/// Probes audio files for metadata.
/// </summary>
- public partial class AudioFileProber
+ public class AudioFileProber
{
- // Default LUFS value for use with the web interface, at -18db gain will be 1(no db gain).
- private const float DefaultLUFSValue = -18;
-
- private readonly ILogger<AudioFileProber> _logger;
private readonly IMediaEncoder _mediaEncoder;
private readonly IItemRepository _itemRepo;
private readonly ILibraryManager _libraryManager;
+ private readonly ILogger<AudioFileProber> _logger;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly LyricResolver _lyricResolver;
private readonly ILyricManager _lyricManager;
@@ -58,21 +54,15 @@ namespace MediaBrowser.Providers.MediaInfo
LyricResolver lyricResolver,
ILyricManager lyricManager)
{
- _logger = logger;
_mediaEncoder = mediaEncoder;
_itemRepo = itemRepo;
_libraryManager = libraryManager;
+ _logger = logger;
_mediaSourceManager = mediaSourceManager;
_lyricResolver = lyricResolver;
_lyricManager = lyricManager;
}
- [GeneratedRegex(@"I:\s+(.*?)\s+LUFS")]
- private static partial Regex LUFSRegex();
-
- [GeneratedRegex(@"REPLAYGAIN_TRACK_GAIN:\s+-?([0-9.]+)\s+dB")]
- private static partial Regex ReplayGainTagRegex();
-
/// <summary>
/// Probes the specified item for metadata.
/// </summary>
@@ -115,97 +105,6 @@ namespace MediaBrowser.Providers.MediaInfo
await FetchAsync(item, result, options, cancellationToken).ConfigureAwait(false);
}
- var libraryOptions = _libraryManager.GetLibraryOptions(item);
- bool foundLUFSValue = false;
-
- if (libraryOptions.UseReplayGainTags)
- {
- using (var process = new Process()
- {
- StartInfo = new ProcessStartInfo
- {
- FileName = _mediaEncoder.ProbePath,
- Arguments = $"-hide_banner -i \"{path}\"",
- RedirectStandardOutput = false,
- RedirectStandardError = true
- },
- })
- {
- try
- {
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error starting ffmpeg");
-
- throw;
- }
-
- using var reader = process.StandardError;
- var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
- cancellationToken.ThrowIfCancellationRequested();
- Match split = ReplayGainTagRegex().Match(output);
-
- if (split.Success)
- {
- item.LUFS = DefaultLUFSValue - float.Parse(split.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
- foundLUFSValue = true;
- }
- else
- {
- item.LUFS = DefaultLUFSValue;
- }
- }
- }
-
- if (libraryOptions.EnableLUFSScan && !foundLUFSValue)
- {
- using (var process = new Process()
- {
- StartInfo = new ProcessStartInfo
- {
- FileName = _mediaEncoder.EncoderPath,
- Arguments = $"-hide_banner -i \"{path}\" -af ebur128=framelog=verbose -f null -",
- RedirectStandardOutput = false,
- RedirectStandardError = true
- },
- })
- {
- try
- {
- process.Start();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error starting ffmpeg");
-
- throw;
- }
-
- using var reader = process.StandardError;
- var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
- cancellationToken.ThrowIfCancellationRequested();
- MatchCollection split = LUFSRegex().Matches(output);
-
- if (split.Count != 0)
- {
- item.LUFS = float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
- }
- else
- {
- item.LUFS = DefaultLUFSValue;
- }
- }
- }
-
- if (!libraryOptions.EnableLUFSScan && !libraryOptions.UseReplayGainTags)
- {
- item.LUFS = DefaultLUFSValue;
- }
-
- _logger.LogDebug("LUFS for {ItemName} is {LUFS}.", item.Name, item.LUFS);
-
return ItemUpdateType.MetadataImport;
}
@@ -230,14 +129,20 @@ namespace MediaBrowser.Providers.MediaInfo
audio.Size = mediaInfo.Size;
audio.PremiereDate = mediaInfo.PremiereDate;
+ // Add external lyrics first to prevent the lrc file get overwritten on first scan
+ var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
+ AddExternalLyrics(audio, mediaStreams, options);
+ var tryExtractEmbeddedLyrics = mediaStreams.All(s => s.Type != MediaStreamType.Lyric);
+
if (!audio.IsLocked)
{
- await FetchDataFromTags(audio, options).ConfigureAwait(false);
+ await FetchDataFromTags(audio, mediaInfo, options, tryExtractEmbeddedLyrics).ConfigureAwait(false);
+ if (tryExtractEmbeddedLyrics)
+ {
+ AddExternalLyrics(audio, mediaStreams, options);
+ }
}
- var mediaStreams = new List<MediaStream>(mediaInfo.MediaStreams);
- AddExternalLyrics(audio, mediaStreams, options);
-
audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric);
_itemRepo.SaveMediaStreams(audio.Id, mediaStreams, cancellationToken);
@@ -247,177 +152,221 @@ namespace MediaBrowser.Providers.MediaInfo
/// Fetches data from the tags.
/// </summary>
/// <param name="audio">The <see cref="Audio"/>.</param>
+ /// <param name="mediaInfo">The <see cref="Model.MediaInfo.MediaInfo"/>.</param>
/// <param name="options">The <see cref="MetadataRefreshOptions"/>.</param>
- private async Task FetchDataFromTags(Audio audio, MetadataRefreshOptions options)
+ /// <param name="tryExtractEmbeddedLyrics">Whether to extract embedded lyrics to lrc file. </param>
+ private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions options, bool tryExtractEmbeddedLyrics)
{
- using var file = TagLib.File.Create(audio.Path);
- var tagTypes = file.TagTypesOnDisk;
Tag? tags = null;
-
- if (tagTypes.HasFlag(TagTypes.Id3v2))
- {
- tags = file.GetTag(TagTypes.Id3v2);
- }
- else if (tagTypes.HasFlag(TagTypes.Ape))
- {
- tags = file.GetTag(TagTypes.Ape);
- }
- else if (tagTypes.HasFlag(TagTypes.FlacMetadata))
- {
- tags = file.GetTag(TagTypes.FlacMetadata);
- }
- else if (tagTypes.HasFlag(TagTypes.Apple))
+ try
{
- tags = file.GetTag(TagTypes.Apple);
- }
- else if (tagTypes.HasFlag(TagTypes.Xiph))
- {
- tags = file.GetTag(TagTypes.Xiph);
- }
- else if (tagTypes.HasFlag(TagTypes.AudibleMetadata))
- {
- tags = file.GetTag(TagTypes.AudibleMetadata);
+ using var file = TagLib.File.Create(audio.Path);
+ var tagTypes = file.TagTypesOnDisk;
+
+ if (tagTypes.HasFlag(TagTypes.Id3v2))
+ {
+ tags = file.GetTag(TagTypes.Id3v2);
+ }
+ else if (tagTypes.HasFlag(TagTypes.Ape))
+ {
+ tags = file.GetTag(TagTypes.Ape);
+ }
+ else if (tagTypes.HasFlag(TagTypes.FlacMetadata))
+ {
+ tags = file.GetTag(TagTypes.FlacMetadata);
+ }
+ else if (tagTypes.HasFlag(TagTypes.Apple))
+ {
+ tags = file.GetTag(TagTypes.Apple);
+ }
+ else if (tagTypes.HasFlag(TagTypes.Xiph))
+ {
+ tags = file.GetTag(TagTypes.Xiph);
+ }
+ else if (tagTypes.HasFlag(TagTypes.AudibleMetadata))
+ {
+ tags = file.GetTag(TagTypes.AudibleMetadata);
+ }
+ else if (tagTypes.HasFlag(TagTypes.Id3v1))
+ {
+ tags = file.GetTag(TagTypes.Id3v1);
+ }
}
- else if (tagTypes.HasFlag(TagTypes.Id3v1))
+ catch (Exception e)
{
- tags = file.GetTag(TagTypes.Id3v1);
+ _logger.LogWarning(e, "TagLib-Sharp does not support this audio");
}
- if (tags is not null)
+ tags ??= new TagLib.Id3v2.Tag();
+ tags.AlbumArtists ??= mediaInfo.AlbumArtists;
+ tags.Album ??= mediaInfo.Album;
+ tags.Title ??= mediaInfo.Name;
+ tags.Year = tags.Year == 0U ? Convert.ToUInt32(mediaInfo.ProductionYear, CultureInfo.InvariantCulture) : tags.Year;
+ tags.Performers ??= mediaInfo.Artists;
+ tags.Genres ??= mediaInfo.Genres;
+ tags.Track = tags.Track == 0U ? Convert.ToUInt32(mediaInfo.IndexNumber, CultureInfo.InvariantCulture) : tags.Track;
+ tags.Disc = tags.Disc == 0U ? Convert.ToUInt32(mediaInfo.ParentIndexNumber, CultureInfo.InvariantCulture) : tags.Disc;
+
+ if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
{
- if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
+ var people = new List<PersonInfo>();
+ var albumArtists = tags.AlbumArtists;
+ foreach (var albumArtist in albumArtists)
{
- var people = new List<PersonInfo>();
- var albumArtists = tags.AlbumArtists;
- foreach (var albumArtist in albumArtists)
+ if (!string.IsNullOrEmpty(albumArtist))
{
- if (!string.IsNullOrEmpty(albumArtist))
+ PeopleHelper.AddPerson(people, new PersonInfo
{
- PeopleHelper.AddPerson(people, new PersonInfo
- {
- Name = albumArtist,
- Type = PersonKind.AlbumArtist
- });
- }
+ Name = albumArtist,
+ Type = PersonKind.AlbumArtist
+ });
}
+ }
- var performers = tags.Performers;
- foreach (var performer in performers)
+ var performers = tags.Performers;
+ foreach (var performer in performers)
+ {
+ if (!string.IsNullOrEmpty(performer))
{
- if (!string.IsNullOrEmpty(performer))
+ PeopleHelper.AddPerson(people, new PersonInfo
{
- PeopleHelper.AddPerson(people, new PersonInfo
- {
- Name = performer,
- Type = PersonKind.Artist
- });
- }
+ Name = performer,
+ Type = PersonKind.Artist
+ });
}
+ }
- foreach (var composer in tags.Composers)
+ foreach (var composer in tags.Composers)
+ {
+ if (!string.IsNullOrEmpty(composer))
{
- if (!string.IsNullOrEmpty(composer))
+ PeopleHelper.AddPerson(people, new PersonInfo
{
- PeopleHelper.AddPerson(people, new PersonInfo
- {
- Name = composer,
- Type = PersonKind.Composer
- });
- }
+ Name = composer,
+ Type = PersonKind.Composer
+ });
}
+ }
- _libraryManager.UpdatePeople(audio, people);
-
- if (options.ReplaceAllMetadata && performers.Length != 0)
- {
- audio.Artists = performers;
- }
- else if (!options.ReplaceAllMetadata
- && (audio.Artists is null || audio.Artists.Count == 0))
- {
- audio.Artists = performers;
- }
+ _libraryManager.UpdatePeople(audio, people);
- if (options.ReplaceAllMetadata && albumArtists.Length != 0)
- {
- audio.AlbumArtists = albumArtists;
- }
- else if (!options.ReplaceAllMetadata
- && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
- {
- audio.AlbumArtists = albumArtists;
- }
+ if (options.ReplaceAllMetadata && performers.Length != 0)
+ {
+ audio.Artists = performers;
+ }
+ else if (!options.ReplaceAllMetadata
+ && (audio.Artists is null || audio.Artists.Count == 0))
+ {
+ audio.Artists = performers;
}
- if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(tags.Title))
+ if (albumArtists.Length == 0)
{
- audio.Name = tags.Title;
+ // Album artists not provided, fall back to performers (artists).
+ albumArtists = performers;
}
- if (options.ReplaceAllMetadata)
+ if (options.ReplaceAllMetadata && albumArtists.Length != 0)
{
- audio.Album = tags.Album;
- audio.IndexNumber = Convert.ToInt32(tags.Track);
- audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
+ audio.AlbumArtists = albumArtists;
}
- else
+ else if (!options.ReplaceAllMetadata
+ && (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
{
- audio.Album ??= tags.Album;
- audio.IndexNumber ??= Convert.ToInt32(tags.Track);
- audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc);
+ audio.AlbumArtists = albumArtists;
}
+ }
- if (tags.Year != 0)
- {
- var year = Convert.ToInt32(tags.Year);
- audio.ProductionYear = year;
+ if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(tags.Title))
+ {
+ audio.Name = tags.Title;
+ }
+
+ if (options.ReplaceAllMetadata)
+ {
+ audio.Album = tags.Album;
+ audio.IndexNumber = Convert.ToInt32(tags.Track);
+ audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
+ }
+ else
+ {
+ audio.Album ??= tags.Album;
+ audio.IndexNumber ??= Convert.ToInt32(tags.Track);
+ audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc);
+ }
+
+ if (tags.Year != 0)
+ {
+ var year = Convert.ToInt32(tags.Year);
+ audio.ProductionYear = year;
- if (!audio.PremiereDate.HasValue)
+ if (!audio.PremiereDate.HasValue)
+ {
+ try
{
audio.PremiereDate = new DateTime(year, 01, 01);
}
+ catch (ArgumentOutOfRangeException ex)
+ {
+ _logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, tags.Year);
+ }
}
+ }
- if (!audio.LockedFields.Contains(MetadataField.Genres))
- {
- audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0
- ? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
- : audio.Genres;
- }
+ if (!audio.LockedFields.Contains(MetadataField.Genres))
+ {
+ audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0
+ ? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
+ : audio.Genres;
+ }
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
- {
- audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
- }
+ if (!double.IsNaN(tags.ReplayGainTrackGain))
+ {
+ audio.NormalizationGain = (float)tags.ReplayGainTrackGain;
+ }
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
- {
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
- }
+ if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
+ && !string.IsNullOrEmpty(tags.MusicBrainzArtistId))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
+ }
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
- {
- audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
- }
+ if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
+ && !string.IsNullOrEmpty(tags.MusicBrainzReleaseArtistId))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
+ }
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
- {
- audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
- }
+ if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
+ && !string.IsNullOrEmpty(tags.MusicBrainzReleaseId))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
+ }
- if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
- {
- audio.SetProviderId(MetadataProvider.MusicBrainzTrack, tags.MusicBrainzTrackId);
- }
+ if ((options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
+ && !string.IsNullOrEmpty(tags.MusicBrainzReleaseGroupId))
+ {
+ audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
+ }
- // Save extracted lyrics if they exist,
- // and if we are replacing all metadata or the audio doesn't yet have lyrics.
- if (!string.IsNullOrWhiteSpace(tags.Lyrics)
- && (options.ReplaceAllMetadata || audio.GetMediaStreams().All(s => s.Type != MediaStreamType.Lyric)))
+ if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
+ {
+ // Fallback to ffprobe as TagLib incorrectly provides recording MBID in `tags.MusicBrainzTrackId`.
+ // See https://github.com/mono/taglib-sharp/issues/304
+ var trackMbId = mediaInfo.GetProviderId(MetadataProvider.MusicBrainzTrack);
+ if (trackMbId is not null)
{
- await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false);
+ audio.SetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId);
}
}
+
+ // Save extracted lyrics if they exist,
+ // and if the audio doesn't yet have lyrics.
+ if (!string.IsNullOrWhiteSpace(tags.Lyrics)
+ && tryExtractEmbeddedLyrics)
+ {
+ await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false);
+ }
}
private void AddExternalLyrics(
@@ -429,7 +378,10 @@ namespace MediaBrowser.Providers.MediaInfo
var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, false);
audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray();
- currentStreams.AddRange(externalLyricFiles);
+ if (externalLyricFiles.Count > 0)
+ {
+ currentStreams.Add(externalLyricFiles[0]);
+ }
}
}
}
diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
index 5d0fccbe1..246ba2733 100644
--- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
+++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
@@ -124,11 +124,8 @@ namespace MediaBrowser.Providers.MediaInfo
// Get BD disc information
blurayDiscInfo = GetBDInfo(item.Path);
- // Get playable .m2ts files
- var m2ts = _mediaEncoder.GetPrimaryPlaylistM2tsFiles(item.Path);
-
// Return if no playable .m2ts files are found
- if (blurayDiscInfo is null || blurayDiscInfo.Files.Length == 0 || m2ts.Count == 0)
+ if (blurayDiscInfo is null || blurayDiscInfo.Files.Length == 0)
{
_logger.LogError("No playable .m2ts files found in Blu-ray structure, skipping FFprobe.");
return ItemUpdateType.MetadataImport;
@@ -138,7 +135,7 @@ namespace MediaBrowser.Providers.MediaInfo
mediaInfoResult = await GetMediaInfo(
new Video
{
- Path = m2ts[0]
+ Path = blurayDiscInfo.Files[0]
},
cancellationToken).ConfigureAwait(false);
}
@@ -358,6 +355,10 @@ namespace MediaBrowser.Providers.MediaInfo
blurayVideoStream.BitRate = blurayVideoStream.BitRate.GetValueOrDefault() == 0 ? ffmpegVideoStream.BitRate : blurayVideoStream.BitRate;
blurayVideoStream.Width = blurayVideoStream.Width.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Width;
blurayVideoStream.Height = blurayVideoStream.Height.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Height;
+ blurayVideoStream.ColorRange = ffmpegVideoStream.ColorRange;
+ blurayVideoStream.ColorSpace = ffmpegVideoStream.ColorSpace;
+ blurayVideoStream.ColorTransfer = ffmpegVideoStream.ColorTransfer;
+ blurayVideoStream.ColorPrimaries = ffmpegVideoStream.ColorPrimaries;
}
}
diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
index 8bb8d5bb4..04da8fb88 100644
--- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs
@@ -1,6 +1,7 @@
#nullable disable
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
@@ -141,19 +142,15 @@ namespace MediaBrowser.Providers.MediaInfo
&& item.SupportsLocalMetadata
&& !video.IsPlaceHolder)
{
- if (!video.SubtitleFiles.SequenceEqual(
- _subtitleResolver.GetExternalFiles(video, directoryService, false)
- .Select(info => info.Path).ToList(),
- StringComparer.Ordinal))
+ var externalFiles = new HashSet<string>(_subtitleResolver.GetExternalFiles(video, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase);
+ if (!new HashSet<string>(video.SubtitleFiles, StringComparer.Ordinal).SetEquals(externalFiles))
{
_logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
return true;
}
- if (!video.AudioFiles.SequenceEqual(
- _audioResolver.GetExternalFiles(video, directoryService, false)
- .Select(info => info.Path).ToList(),
- StringComparer.Ordinal))
+ externalFiles = new HashSet<string>(_audioResolver.GetExternalFiles(video, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase);
+ if (!new HashSet<string>(video.AudioFiles, StringComparer.Ordinal).SetEquals(externalFiles))
{
_logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
return true;
@@ -161,14 +158,14 @@ namespace MediaBrowser.Providers.MediaInfo
}
if (item is Audio audio
- && item.SupportsLocalMetadata
- && !audio.LyricFiles.SequenceEqual(
- _lyricResolver.GetExternalFiles(audio, directoryService, false)
- .Select(info => info.Path).ToList(),
- StringComparer.Ordinal))
+ && item.SupportsLocalMetadata)
{
- _logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path);
- return true;
+ var externalFiles = new HashSet<string>(_lyricResolver.GetExternalFiles(audio, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase);
+ if (!new HashSet<string>(audio.LyricFiles, StringComparer.Ordinal).SetEquals(externalFiles))
+ {
+ _logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path);
+ return true;
+ }
}
return false;
diff --git a/MediaBrowser.Providers/Movies/MovieMetadataService.cs b/MediaBrowser.Providers/Movies/MovieMetadataService.cs
index 984a3c122..8997ddc64 100644
--- a/MediaBrowser.Providers/Movies/MovieMetadataService.cs
+++ b/MediaBrowser.Providers/Movies/MovieMetadataService.cs
@@ -24,22 +24,6 @@ namespace MediaBrowser.Providers.Movies
}
/// <inheritdoc />
- protected override bool IsFullLocalMetadata(Movie item)
- {
- if (string.IsNullOrWhiteSpace(item.Overview))
- {
- return false;
- }
-
- if (!item.ProductionYear.HasValue)
- {
- return false;
- }
-
- return base.IsFullLocalMetadata(item);
- }
-
- /// <inheritdoc />
protected override void MergeData(MetadataResult<Movie> source, MetadataResult<Movie> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
diff --git a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs
index ad0c5aaa7..e77d2fa8a 100644
--- a/MediaBrowser.Providers/Movies/TrailerMetadataService.cs
+++ b/MediaBrowser.Providers/Movies/TrailerMetadataService.cs
@@ -1,5 +1,6 @@
#pragma warning disable CS1591
+using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -24,22 +25,6 @@ namespace MediaBrowser.Providers.Movies
}
/// <inheritdoc />
- protected override bool IsFullLocalMetadata(Trailer item)
- {
- if (string.IsNullOrWhiteSpace(item.Overview))
- {
- return false;
- }
-
- if (!item.ProductionYear.HasValue)
- {
- return false;
- }
-
- return base.IsFullLocalMetadata(item);
- }
-
- /// <inheritdoc />
protected override void MergeData(MetadataResult<Trailer> source, MetadataResult<Trailer> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
base.MergeData(source, target, lockedFields, replaceData, mergeMetadataSettings);
@@ -48,6 +33,10 @@ namespace MediaBrowser.Providers.Movies
{
target.Item.TrailerTypes = source.Item.TrailerTypes;
}
+ else
+ {
+ 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 e4f34776b..a39bd16ce 100644
--- a/MediaBrowser.Providers/Music/AlbumMetadataService.cs
+++ b/MediaBrowser.Providers/Music/AlbumMetadataService.cs
@@ -225,6 +225,10 @@ namespace MediaBrowser.Providers.Music
{
targetItem.Artists = sourceItem.Artists;
}
+ else
+ {
+ targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
+ }
if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
{
diff --git a/MediaBrowser.Providers/Music/AudioMetadataService.cs b/MediaBrowser.Providers/Music/AudioMetadataService.cs
index a5b7cb895..7b25bc0e4 100644
--- a/MediaBrowser.Providers/Music/AudioMetadataService.cs
+++ b/MediaBrowser.Providers/Music/AudioMetadataService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Library;
@@ -60,6 +61,10 @@ namespace MediaBrowser.Providers.Music
{
targetItem.Artists = sourceItem.Artists;
}
+ else
+ {
+ targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
+ }
if (replaceData || string.IsNullOrEmpty(targetItem.Album))
{
diff --git a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs
index b97b76630..24c4b5501 100644
--- a/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs
+++ b/MediaBrowser.Providers/Music/MusicVideoMetadataService.cs
@@ -1,5 +1,6 @@
#pragma warning disable CS1591
+using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -45,6 +46,10 @@ namespace MediaBrowser.Providers.Music
{
targetItem.Artists = sourceItem.Artists;
}
+ else
+ {
+ targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
+ }
}
}
}
diff --git a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
index 9bd36f25c..51a3ba0c7 100644
--- a/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
+++ b/MediaBrowser.Providers/Playlists/PlaylistItemsProvider.cs
@@ -1,170 +1,227 @@
#nullable disable
-#pragma warning disable CS1591
-
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
using PlaylistsNET.Content;
-namespace MediaBrowser.Providers.Playlists
+namespace MediaBrowser.Providers.Playlists;
+
+/// <summary>
+/// Local playlist provider.
+/// </summary>
+public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
+ IHasOrder,
+ IForcedProvider,
+ IHasItemChangeMonitor
{
- public class PlaylistItemsProvider : ICustomMetadataProvider<Playlist>,
- IHasOrder,
- IForcedProvider,
- IPreRefreshProvider,
- IHasItemChangeMonitor
+ private readonly IFileSystem _fileSystem;
+ private readonly ILibraryManager _libraryManager;
+ private readonly ILogger<PlaylistItemsProvider> _logger;
+ private readonly CollectionType[] _ignoredCollections = [CollectionType.livetv, CollectionType.boxsets, CollectionType.playlists];
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlaylistItemsProvider"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{PlaylistItemsProvider}"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ public PlaylistItemsProvider(ILogger<PlaylistItemsProvider> logger, ILibraryManager libraryManager, IFileSystem fileSystem)
{
- private readonly ILogger<PlaylistItemsProvider> _logger;
-
- public PlaylistItemsProvider(ILogger<PlaylistItemsProvider> logger)
- {
- _logger = logger;
- }
+ _logger = logger;
+ _libraryManager = libraryManager;
+ _fileSystem = fileSystem;
+ }
- public string Name => "Playlist Reader";
+ /// <inheritdoc />
+ public string Name => "Playlist Item Provider";
- // Run last
- public int Order => 100;
+ /// <inheritdoc />
+ public int Order => 100;
- public Task<ItemUpdateType> FetchAsync(Playlist item, MetadataRefreshOptions options, CancellationToken cancellationToken)
+ /// <inheritdoc />
+ public Task<MetadataResult<Playlist>> GetMetadata(
+ ItemInfo info,
+ IDirectoryService directoryService,
+ CancellationToken cancellationToken)
+ {
+ var result = new MetadataResult<Playlist>()
{
- var path = item.Path;
- if (!Playlist.IsPlaylistFile(path))
+ Item = new Playlist
{
- return Task.FromResult(ItemUpdateType.None);
+ Path = info.Path
}
+ };
+ Fetch(result);
- var extension = Path.GetExtension(path);
- if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase))
- {
- return Task.FromResult(ItemUpdateType.None);
- }
+ return Task.FromResult(result);
+ }
- using (var stream = File.OpenRead(path))
- {
- var items = GetItems(stream, extension).ToArray();
+ private void Fetch(MetadataResult<Playlist> result)
+ {
+ var item = result.Item;
+ var path = item.Path;
+ if (!Playlist.IsPlaylistFile(path))
+ {
+ return;
+ }
- item.LinkedChildren = items;
- }
+ var extension = Path.GetExtension(path);
+ if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
- return Task.FromResult(ItemUpdateType.None);
+ var items = GetItems(path, extension).ToArray();
+ if (items.Length > 0)
+ {
+ result.HasMetadata = true;
+ item.LinkedChildren = items;
}
- private IEnumerable<LinkedChild> GetItems(Stream stream, string extension)
+ return;
+ }
+
+ private IEnumerable<LinkedChild> GetItems(string path, string extension)
+ {
+ var libraryRoots = _libraryManager.GetUserRootFolder().Children
+ .OfType<CollectionFolder>()
+ .Where(f => f.CollectionType.HasValue && !_ignoredCollections.Contains(f.CollectionType.Value))
+ .SelectMany(f => f.PhysicalLocations)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ using (var stream = File.OpenRead(path))
{
if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase))
{
- return GetWplItems(stream);
+ return GetWplItems(stream, path, libraryRoots);
}
if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase))
{
- return GetZplItems(stream);
+ return GetZplItems(stream, path, libraryRoots);
}
if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase))
{
- return GetM3uItems(stream);
+ return GetM3uItems(stream, path, libraryRoots);
}
if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase))
{
- return GetM3u8Items(stream);
+ return GetM3uItems(stream, path, libraryRoots);
}
if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase))
{
- return GetPlsItems(stream);
+ return GetPlsItems(stream, path, libraryRoots);
}
-
- return Enumerable.Empty<LinkedChild>();
}
- private IEnumerable<LinkedChild> GetPlsItems(Stream stream)
- {
- var content = new PlsContent();
- var playlist = content.GetFromStream(stream);
+ return Enumerable.Empty<LinkedChild>();
+ }
- return playlist.PlaylistEntries.Select(i => new LinkedChild
- {
- Path = i.Path,
- Type = LinkedChildType.Manual
- });
- }
+ private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots)
+ {
+ var content = new PlsContent();
+ var playlist = content.GetFromStream(stream);
- private IEnumerable<LinkedChild> GetM3u8Items(Stream stream)
- {
- var content = new M3uContent();
- var playlist = content.GetFromStream(stream);
+ return playlist.PlaylistEntries
+ .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
+ .Where(i => i is not null);
+ }
- return playlist.PlaylistEntries.Select(i => new LinkedChild
- {
- Path = i.Path,
- Type = LinkedChildType.Manual
- });
- }
+ private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots)
+ {
+ var content = new M3uContent();
+ var playlist = content.GetFromStream(stream);
- private IEnumerable<LinkedChild> GetM3uItems(Stream stream)
- {
- var content = new M3uContent();
- var playlist = content.GetFromStream(stream);
+ return playlist.PlaylistEntries
+ .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
+ .Where(i => i is not null);
+ }
- return playlist.PlaylistEntries.Select(i => new LinkedChild
- {
- Path = i.Path,
- Type = LinkedChildType.Manual
- });
- }
+ private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots)
+ {
+ var content = new ZplContent();
+ var playlist = content.GetFromStream(stream);
- private IEnumerable<LinkedChild> GetZplItems(Stream stream)
- {
- var content = new ZplContent();
- var playlist = content.GetFromStream(stream);
+ return playlist.PlaylistEntries
+ .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
+ .Where(i => i is not null);
+ }
+
+ private IEnumerable<LinkedChild> GetWplItems(Stream stream, string playlistPath, List<string> libraryRoots)
+ {
+ var content = new WplContent();
+ var playlist = content.GetFromStream(stream);
- return playlist.PlaylistEntries.Select(i => new LinkedChild
+ return playlist.PlaylistEntries
+ .Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
+ .Where(i => i is not null);
+ }
+
+ private LinkedChild GetLinkedChild(string itemPath, string playlistPath, List<string> libraryRoots)
+ {
+ if (TryGetPlaylistItemPath(itemPath, playlistPath, libraryRoots, out var parsedPath))
+ {
+ return new LinkedChild
{
- Path = i.Path,
+ Path = parsedPath,
Type = LinkedChildType.Manual
- });
+ };
}
- private IEnumerable<LinkedChild> GetWplItems(Stream stream)
+ return null;
+ }
+
+ private bool TryGetPlaylistItemPath(string itemPath, string playlistPath, List<string> libraryPaths, out string path)
+ {
+ path = null;
+ string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath);
+ if (!File.Exists(pathToCheck))
{
- var content = new WplContent();
- var playlist = content.GetFromStream(stream);
+ return false;
+ }
- return playlist.PlaylistEntries.Select(i => new LinkedChild
+ foreach (var libraryPath in libraryPaths)
+ {
+ if (pathToCheck.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase))
{
- Path = i.Path,
- Type = LinkedChildType.Manual
- });
+ path = pathToCheck;
+ return true;
+ }
}
- public bool HasChanged(BaseItem item, IDirectoryService directoryService)
- {
- var path = item.Path;
+ return false;
+ }
- if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
+ /// <inheritdoc />
+ public bool HasChanged(BaseItem item, IDirectoryService directoryService)
+ {
+ var path = item.Path;
+ if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
+ {
+ var file = directoryService.GetFile(path);
+ if (file is not null && file.LastWriteTimeUtc != item.DateModified)
{
- var file = directoryService.GetFile(path);
- if (file is not null && file.LastWriteTimeUtc != item.DateModified)
- {
- _logger.LogDebug("Refreshing {0} due to date modified timestamp change.", path);
- return true;
- }
+ _logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path);
+ return true;
}
-
- return false;
}
+
+ return false;
}
}
diff --git a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
index 1bd000a48..43889bfbf 100644
--- a/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
+++ b/MediaBrowser.Providers/Playlists/PlaylistMetadataService.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System.Collections.Generic;
+using System.Linq;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
@@ -49,8 +50,24 @@ namespace MediaBrowser.Providers.Playlists
if (mergeMetadataSettings)
{
targetItem.PlaylistMediaType = sourceItem.PlaylistMediaType;
- targetItem.LinkedChildren = sourceItem.LinkedChildren;
- targetItem.Shares = sourceItem.Shares;
+
+ 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();
+ }
}
}
}
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
index d0bd7d609..c35324746 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
@@ -250,7 +250,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
// If we have a release ID but not a release group ID, lookup the release group
if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
{
- var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Releases, cancellationToken).ConfigureAwait(false);
+ var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
releaseGroupId = release.ReleaseGroup?.Id.ToString();
result.HasMetadata = true;
}
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index 3fd4ae1fc..c750caa1c 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
@@ -447,11 +447,6 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var actorList = result.Actors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var actor in actorList)
{
- if (string.IsNullOrWhiteSpace(actor))
- {
- continue;
- }
-
var person = new PersonInfo
{
Name = actor,
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index 8dc2d6938..d8476bd47 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -278,17 +278,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
series.RunTimeTicks = seriesResult.EpisodeRunTime.Select(i => TimeSpan.FromMinutes(i).Ticks).FirstOrDefault();
- if (string.Equals(seriesResult.Status, "Ended", StringComparison.OrdinalIgnoreCase)
- || string.Equals(seriesResult.Status, "Canceled", StringComparison.OrdinalIgnoreCase))
+ if (Emby.Naming.TV.TvParserHelpers.TryParseSeriesStatus(seriesResult.Status, out var seriesStatus))
{
- series.Status = SeriesStatus.Ended;
- series.EndDate = seriesResult.LastAirDate;
- }
- else
- {
- series.Status = SeriesStatus.Continuing;
+ series.Status = seriesStatus;
}
+ series.EndDate = seriesResult.LastAirDate;
series.PremiereDate = seriesResult.FirstAirDate;
var ids = seriesResult.ExternalIds;
diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
index f68b3cee6..ae5e1090a 100644
--- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
+++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
@@ -117,7 +117,7 @@ namespace MediaBrowser.Providers.Subtitles
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error downloading subtitles from {0}", i.Name);
+ _logger.LogError(ex, "Error downloading subtitles from {Name}", i.Name);
return Array.Empty<RemoteSubtitleInfo>();
}
});
@@ -205,72 +205,71 @@ namespace MediaBrowser.Providers.Subtitles
saveFileName += ".sdh";
}
- saveFileName += "." + response.Format.ToLowerInvariant();
-
if (saveInMediaFolder)
{
var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName));
- // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, bad path.");
- if (mediaFolderPath.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal))
- {
- savePaths.Add(mediaFolderPath);
- }
+ savePaths.Add(mediaFolderPath);
}
var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
- // TODO: Add some error to the user: return BadRequest("Could not save subtitle, bad path.");
- if (internalPath.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal))
- {
- savePaths.Add(internalPath);
- }
+ savePaths.Add(internalPath);
- if (savePaths.Count > 0)
- {
- await TrySaveToFiles(memoryStream, savePaths).ConfigureAwait(false);
- }
- else
- {
- _logger.LogError("An uploaded subtitle could not be saved because the resulting paths were invalid.");
- }
+ await TrySaveToFiles(memoryStream, savePaths, video, response.Format.ToLowerInvariant()).ConfigureAwait(false);
}
}
- private async Task TrySaveToFiles(Stream stream, List<string> savePaths)
+ private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
{
List<Exception>? exs = null;
foreach (var savePath in savePaths)
{
- _logger.LogInformation("Saving subtitles to {SavePath}", savePath);
-
- _monitor.ReportFileSystemChangeBeginning(savePath);
-
+ var path = savePath + "." + extension;
try
{
- Directory.CreateDirectory(Path.GetDirectoryName(savePath) ?? throw new InvalidOperationException("Path can't be a root directory."));
+ if (path.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal)
+ || path.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal))
+ {
+ var fileExists = File.Exists(path);
+ var counter = 0;
+
+ while (fileExists)
+ {
+ path = string.Format(CultureInfo.InvariantCulture, "{0}.{1}.{2}", savePath, counter, extension);
+ fileExists = File.Exists(path);
+ counter++;
+ }
+
+ _logger.LogInformation("Saving subtitles to {SavePath}", path);
+ _monitor.ReportFileSystemChangeBeginning(path);
+
+ Directory.CreateDirectory(Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Path can't be a root directory."));
- var fileOptions = AsyncFile.WriteOptions;
- fileOptions.Mode = FileMode.CreateNew;
- fileOptions.PreallocationSize = stream.Length;
- var fs = new FileStream(savePath, fileOptions);
- await using (fs.ConfigureAwait(false))
+ var fileOptions = AsyncFile.WriteOptions;
+ fileOptions.Mode = FileMode.CreateNew;
+ fileOptions.PreallocationSize = stream.Length;
+ var fs = new FileStream(path, fileOptions);
+ await using (fs.ConfigureAwait(false))
+ {
+ await stream.CopyToAsync(fs).ConfigureAwait(false);
+ }
+
+ return;
+ }
+ else
{
- await stream.CopyToAsync(fs).ConfigureAwait(false);
+ // TODO: Add some error handling to the API user: return BadRequest("Could not save subtitle, bad path.");
+ _logger.LogError("An uploaded subtitle could not be saved because the resulting path was invalid.");
}
-
- return;
}
catch (Exception ex)
{
-// Bug in analyzer -- https://github.com/dotnet/roslyn-analyzers/issues/5160
-#pragma warning disable CA1508
- (exs ??= new List<Exception>()).Add(ex);
-#pragma warning restore CA1508
+ (exs ??= []).Add(ex);
}
finally
{
- _monitor.ReportFileSystemChangeComplete(savePath, false);
+ _monitor.ReportFileSystemChangeComplete(path, false);
}
stream.Position = 0;
diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
index 01c07d633..b03d6ffb5 100644
--- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs
+++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
@@ -35,30 +36,33 @@ namespace MediaBrowser.Providers.TV
_localizationManager = localizationManager;
}
- /// <inheritdoc />
- protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
+ public override async Task<ItemUpdateType> RefreshMetadata(BaseItem item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
{
- await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
+ if (item is Series series)
+ {
+ var seasons = series.GetRecursiveChildren(i => i is Season).ToList();
- RemoveObsoleteEpisodes(item);
- RemoveObsoleteSeasons(item);
- await UpdateAndCreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
+ foreach (var season in seasons)
+ {
+ var hasUpdate = refreshOptions != null && season.BeforeMetadataRefresh(refreshOptions.ReplaceAllMetadata);
+ if (hasUpdate)
+ {
+ await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ return await base.RefreshMetadata(item, refreshOptions, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
- protected override bool IsFullLocalMetadata(Series item)
+ protected override async Task AfterMetadataRefresh(Series item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
{
- if (string.IsNullOrWhiteSpace(item.Overview))
- {
- return false;
- }
-
- if (!item.ProductionYear.HasValue)
- {
- return false;
- }
+ await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
- return base.IsFullLocalMetadata(item);
+ RemoveObsoleteEpisodes(item);
+ await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
+ RemoveObsoleteSeasons(item);
}
/// <inheritdoc />
@@ -68,20 +72,6 @@ namespace MediaBrowser.Providers.TV
var sourceItem = source.Item;
var targetItem = target.Item;
- var sourceSeasonNames = sourceItem.SeasonNames;
- var targetSeasonNames = targetItem.SeasonNames;
-
- if (replaceData || targetSeasonNames.Count == 0)
- {
- targetItem.SeasonNames = sourceSeasonNames;
- }
- else if (targetSeasonNames.Count != sourceSeasonNames.Count || !sourceSeasonNames.Keys.All(targetSeasonNames.ContainsKey))
- {
- foreach (var (number, name) in sourceSeasonNames)
- {
- targetSeasonNames.TryAdd(number, name);
- }
- }
if (replaceData || string.IsNullOrEmpty(targetItem.AirTime))
{
@@ -101,7 +91,7 @@ namespace MediaBrowser.Providers.TV
private void RemoveObsoleteSeasons(Series series)
{
- // TODO Legacy. It's not really "physical" seasons as any virtual seasons are always converted to non-virtual in UpdateAndCreateSeasonsAsync.
+ // 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>())
@@ -129,7 +119,8 @@ namespace MediaBrowser.Providers.TV
virtualSeason,
new DeleteOptions
{
- DeleteFileLocation = true
+ // Internal metadata paths are removed regardless of this.
+ DeleteFileLocation = false
},
false);
}
@@ -138,7 +129,7 @@ namespace MediaBrowser.Providers.TV
private void RemoveObsoleteEpisodes(Series series)
{
- var episodes = series.GetEpisodes(null, new DtoOptions()).OfType<Episode>().ToList();
+ var episodes = series.GetEpisodes(null, new DtoOptions(), true).OfType<Episode>().ToList();
var numberOfEpisodes = episodes.Count;
// TODO: O(n^2), but can it be done faster without overcomplicating it?
for (var i = 0; i < numberOfEpisodes; i++)
@@ -186,7 +177,8 @@ namespace MediaBrowser.Providers.TV
episode,
new DeleteOptions
{
- DeleteFileLocation = true
+ // Internal metadata paths are removed regardless of this.
+ DeleteFileLocation = false
},
false);
}
@@ -194,14 +186,12 @@ namespace MediaBrowser.Providers.TV
/// <summary>
/// Creates seasons for all episodes if they don't exist.
/// If no season number can be determined, a dummy season will be created.
- /// Updates seasons names.
/// </summary>
/// <param name="series">The series.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The async task.</returns>
- private async Task UpdateAndCreateSeasonsAsync(Series series, CancellationToken cancellationToken)
+ private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
{
- var seasonNames = series.SeasonNames;
var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
var seasons = seriesChildren.OfType<Season>().ToList();
var uniqueSeasonNumbers = seriesChildren
@@ -214,20 +204,14 @@ namespace MediaBrowser.Providers.TV
{
// Null season numbers will have a 'dummy' season created because seasons are always required.
var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber);
-
- if (!seasonNumber.HasValue || !seasonNames.TryGetValue(seasonNumber.Value, out var seasonName))
- {
- seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
- }
-
if (existingSeason is null)
{
- var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
- series.AddChild(season);
+ var seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber);
+ await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false);
}
- else if (!string.Equals(existingSeason.Name, seasonName, StringComparison.Ordinal))
+ else if (existingSeason.IsVirtualItem)
{
- existingSeason.Name = seasonName;
+ existingSeason.IsVirtualItem = false;
await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
@@ -241,7 +225,7 @@ namespace MediaBrowser.Providers.TV
/// <param name="seasonNumber">The season number.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The newly created season.</returns>
- private async Task<Season> CreateSeasonAsync(
+ private async Task CreateSeasonAsync(
Series series,
string? seasonName,
int? seasonNumber,
@@ -258,14 +242,12 @@ namespace MediaBrowser.Providers.TV
typeof(Season)),
IsVirtualItem = false,
SeriesId = series.Id,
- SeriesName = series.Name
+ SeriesName = series.Name,
+ SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
};
series.AddChild(season);
-
await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken).ConfigureAwait(false);
-
- return season;
}
private string GetValidSeasonNameForSeries(Series series, string? seasonName, int? seasonNumber)
diff --git a/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs
index f6dcde4f6..9dc4446fc 100644
--- a/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs
+++ b/MediaBrowser.Providers/Trickplay/TrickplayProvider.cs
@@ -101,7 +101,7 @@ public class TrickplayProvider : ICustomMetadataProvider<Episode>,
bool? enableDuringScan = libraryOptions?.ExtractTrickplayImagesDuringLibraryScan;
bool replace = options.ReplaceAllImages;
- if (options.IsAutomated && !enableDuringScan.GetValueOrDefault(false))
+ if (!enableDuringScan.GetValueOrDefault(false))
{
return ItemUpdateType.None;
}
diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
index 97cdc6854..d049c5a8e 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
@@ -519,7 +519,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (reader.TryReadDateTimeExact(nfoConfiguration.ReleaseDateFormat, out var releaseDate))
{
item.PremiereDate = releaseDate;
- item.ProductionYear = releaseDate.Year;
+
+ // Production year can already be set by the year tag
+ item.ProductionYear ??= releaseDate.Year;
}
break;
diff --git a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs
index 16ea5e3ea..af867cd59 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs
@@ -117,9 +117,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
var artist = reader.ReadNormalizedString();
if (!string.IsNullOrEmpty(artist) && item is MusicVideo artistVideo)
{
- var list = artistVideo.Artists.ToList();
- list.Add(artist);
- artistVideo.Artists = list.ToArray();
+ artistVideo.Artists = [..artistVideo.Artists, artist];
}
break;
diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs
index dbcfe7997..d99e11bcd 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs
@@ -1,6 +1,6 @@
-using System;
using System.Globalization;
using System.Xml;
+using Emby.Naming.TV;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Extensions;
@@ -87,7 +87,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (!string.IsNullOrWhiteSpace(status))
{
- if (Enum.TryParse(status, true, out SeriesStatus seriesStatus))
+ if (TvParserHelpers.TryParseSeriesStatus(status, out var seriesStatus))
{
item.Status = seriesStatus;
}
@@ -100,19 +100,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers
break;
}
+ // Season names are processed by SeriesNfoSeasonParser
case "namedseason":
- {
- var parsed = int.TryParse(reader.GetAttribute("number"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber);
- var name = reader.ReadElementContentAsString();
-
- if (!string.IsNullOrWhiteSpace(name) && parsed)
- {
- item.SeasonNames[seasonNumber] = name;
- }
-
- break;
- }
-
+ reader.Skip();
+ break;
default:
base.FetchDataFromXmlNode(reader, itemResult);
break;
diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs
new file mode 100644
index 000000000..44ca3f472
--- /dev/null
+++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoSeasonParser.cs
@@ -0,0 +1,60 @@
+using System.Globalization;
+using System.Xml;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.XbmcMetadata.Parsers
+{
+ /// <summary>
+ /// NFO parser for seasons based on series NFO.
+ /// </summary>
+ public class SeriesNfoSeasonParser : BaseNfoParser<Season>
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SeriesNfoSeasonParser"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
+ /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
+ /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
+ public SeriesNfoSeasonParser(
+ ILogger logger,
+ IConfigurationManager config,
+ IProviderManager providerManager,
+ IUserManager userManager,
+ IUserDataManager userDataManager,
+ IDirectoryService directoryService)
+ : base(logger, config, providerManager, userManager, userDataManager, directoryService)
+ {
+ }
+
+ /// <inheritdoc />
+ protected override bool SupportsUrlAfterClosingXmlTag => true;
+
+ /// <inheritdoc />
+ protected override void FetchDataFromXmlNode(XmlReader reader, MetadataResult<Season> itemResult)
+ {
+ var item = itemResult.Item;
+
+ if (reader.Name == "namedseason")
+ {
+ var parsed = int.TryParse(reader.GetAttribute("number"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber);
+ var name = reader.ReadElementContentAsString();
+
+ if (parsed && !string.IsNullOrWhiteSpace(name) && item.IndexNumber.HasValue && seasonNumber == item.IndexNumber.Value)
+ {
+ item.Name = name;
+ }
+ }
+ else
+ {
+ reader.Skip();
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
index 9b4e1731d..22c065b5d 100644
--- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
+++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs
@@ -42,7 +42,10 @@ namespace MediaBrowser.XbmcMetadata.Providers
try
{
- result.Item = new T();
+ result.Item = new T
+ {
+ IndexNumber = info.IndexNumber
+ };
Fetch(result, path, cancellationToken);
result.HasMetadata = true;
diff --git a/MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs b/MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs
new file mode 100644
index 000000000..b141b7afb
--- /dev/null
+++ b/MediaBrowser.XbmcMetadata/Providers/SeriesNfoSeasonProvider.cs
@@ -0,0 +1,89 @@
+using System.IO;
+using System.Threading;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.IO;
+using MediaBrowser.XbmcMetadata.Parsers;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.XbmcMetadata.Providers
+{
+ /// <summary>
+ /// NFO provider for seasons based on series NFO.
+ /// </summary>
+ public class SeriesNfoSeasonProvider : BaseNfoProvider<Season>
+ {
+ private readonly ILogger<SeriesNfoSeasonProvider> _logger;
+ private readonly IConfigurationManager _config;
+ private readonly IProviderManager _providerManager;
+ private readonly IUserManager _userManager;
+ private readonly IUserDataManager _userDataManager;
+ private readonly IDirectoryService _directoryService;
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SeriesNfoSeasonProvider"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{SeasonFromSeriesNfoProvider}"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="config">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+ /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="userDataManager">Instance of the <see cref="IUserDataManager"/> interface.</param>
+ /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
+ /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+ public SeriesNfoSeasonProvider(
+ ILogger<SeriesNfoSeasonProvider> logger,
+ IFileSystem fileSystem,
+ IConfigurationManager config,
+ IProviderManager providerManager,
+ IUserManager userManager,
+ IUserDataManager userDataManager,
+ IDirectoryService directoryService,
+ ILibraryManager libraryManager)
+ : base(fileSystem)
+ {
+ _logger = logger;
+ _config = config;
+ _providerManager = providerManager;
+ _userManager = userManager;
+ _userDataManager = userDataManager;
+ _directoryService = directoryService;
+ _libraryManager = libraryManager;
+ }
+
+ /// <inheritdoc />
+ protected override void Fetch(MetadataResult<Season> result, string path, CancellationToken cancellationToken)
+ {
+ new SeriesNfoSeasonParser(_logger, _config, _providerManager, _userManager, _userDataManager, _directoryService).Fetch(result, path, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ protected override FileSystemMetadata? GetXmlFile(ItemInfo info, IDirectoryService directoryService)
+ {
+ var seasonPath = info.Path;
+ if (seasonPath is not null)
+ {
+ var path = Path.Combine(seasonPath, "tvshow.nfo");
+ if (Path.Exists(path))
+ {
+ return directoryService.GetFile(path);
+ }
+ }
+
+ var seriesPath = _libraryManager.GetItemById(info.ParentId)?.Path;
+ if (seriesPath is not null)
+ {
+ var path = Path.Combine(seriesPath, "tvshow.nfo");
+ if (Path.Exists(path))
+ {
+ return directoryService.GetFile(path);
+ }
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
index 1399ac307..a547779de 100644
--- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
+++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
@@ -825,7 +825,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
private string GetOutputTrailerUrl(string url)
{
// This is what xbmc expects
- return url.Replace(YouTubeWatchUrl, "plugin://plugin.video.youtube/?action=play_video&videoid=", StringComparison.OrdinalIgnoreCase);
+ return url.Replace(YouTubeWatchUrl, "plugin://plugin.video.youtube/play/?video_id=", StringComparison.OrdinalIgnoreCase);
}
private void AddImages(BaseItem item, XmlWriter writer, ILibraryManager libraryManager)
@@ -947,7 +947,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
if (saveImagePath)
{
var personEntity = libraryManager.GetPerson(person.Name);
- var image = personEntity.GetImageInfo(ImageType.Primary, 0);
+ var image = personEntity?.GetImageInfo(ImageType.Primary, 0);
if (image is not null)
{
diff --git a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs
index 8fa22fad9..bc344d87e 100644
--- a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs
+++ b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs
@@ -45,27 +45,24 @@ namespace MediaBrowser.XbmcMetadata.Savers
internal static IEnumerable<string> GetMovieSavePaths(ItemInfo item)
{
+ var path = item.ContainingFolderPath;
if (item.VideoType == VideoType.Dvd && !item.IsPlaceHolder)
{
- var path = item.ContainingFolderPath;
-
yield return Path.Combine(path, "VIDEO_TS", "VIDEO_TS.nfo");
}
- if (!item.IsPlaceHolder && (item.VideoType == VideoType.Dvd || item.VideoType == VideoType.BluRay))
+ // only allow movie object to read movie.nfo, not owned videos (which will be itemtype video, not movie)
+ if (!item.IsInMixedFolder && item.ItemType == typeof(Movie))
{
- var path = item.ContainingFolderPath;
+ yield return Path.Combine(path, "movie.nfo");
+ }
+ if (!item.IsPlaceHolder && (item.VideoType == VideoType.Dvd || item.VideoType == VideoType.BluRay))
+ {
yield return Path.Combine(path, Path.GetFileName(path) + ".nfo");
}
else
{
- // only allow movie object to read movie.nfo, not owned videos (which will be itemtype video, not movie)
- if (!item.IsInMixedFolder && item.ItemType == typeof(Movie))
- {
- yield return Path.Combine(item.ContainingFolderPath, "movie.nfo");
- }
-
yield return Path.ChangeExtension(item.Path, ".nfo");
}
}
diff --git a/README.md b/README.md
index ec065f260..e5e16c716 100644
--- a/README.md
+++ b/README.md
@@ -16,9 +16,6 @@
<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget">
<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/>
</a>
-<a href="https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=29">
-<img alt="Azure Builds" src="https://dev.azure.com/jellyfin-project/jellyfin/_apis/build/status/Jellyfin%20Server"/>
-</a>
<a href="https://hub.docker.com/r/jellyfin/jellyfin">
<img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/>
</a>
@@ -30,10 +27,7 @@
<img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/>
</a>
<a href="https://matrix.to/#/#jellyfinorg:matrix.org">
-<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfin:matrix.org.svg?logo=matrix"/>
-</a>
-<a href="https://www.reddit.com/r/jellyfin">
-<img alt="Join our Subreddit" src="https://img.shields.io/badge/reddit-r%2Fjellyfin-%23FF5700.svg"/>
+<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/>
</a>
<a href="https://github.com/jellyfin/jellyfin/releases.atom">
<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" />
@@ -153,20 +147,20 @@ API documentation can be viewed at `http://localhost:8096/api-docs/swagger/index
As Jellyfin will run on a container on a github hosted server, JF needs to handle some things differently.
-**NOTE:** Depending on the selected configuration (if you just click 'create codespace' it will create a default configuration one) it might take 20-30 secounds to load all extensions and prepare the enviorment while vscode is already open. Just give it some time and wait until you see `Downloading .NET version(s) 7.0.15~x64 ...... Done!` in the output tab.
+**NOTE:** Depending on the selected configuration (if you just click 'create codespace' it will create a default configuration one) it might take 20-30 seconds to load all extensions and prepare the environment while VS Code is already open. Just give it some time and wait until you see `Downloading .NET version(s) 7.0.15~x64 ...... Done!` in the output tab.
-**NOTE:** If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VsCode window to public.
+**NOTE:** If you want to access the JF instance from outside, like with a WebClient on another PC, remember to set the "ports" in the lower VS Code window to public.
-**NOTE:** When first opening the server instance with any WebUI, you will be send to the login instead of the setup page. Refresh the login page once and you should be redirected to the Setup.
+**NOTE:** When first opening the server instance with any WebUI, you will be sent to the login instead of the setup page. Refresh the login page once and you should be redirected to the Setup.
-There are two configurations for you to chose from.
+There are two configurations for you to choose from.
#### Default - Development Jellyfin Server
-This creates a container that has everything to run and debug the Jellyfin Media server but does not setup anything else. Each time you create a new container you have to run though the whole setup again. There is also no ffmpeg, webclient or media preloaded. Use the `.NET Launch (nowebclient)` lunch config to start the server.
+This creates a container that has everything to run and debug the Jellyfin Media server but does not setup anything else. Each time you create a new container you have to run through the whole setup again. There is also no ffmpeg, webclient or media preloaded. Use the `.NET Launch (nowebclient)` launch config to start the server.
-> Keep in mind that as this has no web client you have to connect to it via an extenal client. This can be just another codespace container running the WebUI. vuejs does not work from the getgo as it does not support the setup steps.
+> Keep in mind that as this has no web client you have to connect to it via an external client. This can be just another codespace container running the WebUI. vuejs does not work from the get-go as it does not support the setup steps.
#### Development Jellyfin Server ffmpeg
-this extens the default server with an default installation of ffmpeg6 though the means described here: https://jellyfin.org/docs/general/installation/linux#repository-manual
+this extends the default server with a default installation of ffmpeg6 though the means described here: https://jellyfin.org/docs/general/installation/linux#repository-manual
If you want to install a specific ffmpeg version, follow the comments embedded in the `.devcontainer/Dev - Server Ffmpeg/install.ffmpeg.sh` file.
Use the `ghcs .NET Launch (nowebclient, ffmpeg)` launch config to run with the jellyfin-ffmpeg enabled.
diff --git a/SharedVersion.cs b/SharedVersion.cs
index 238ef83bd..f98cfbc74 100644
--- a/SharedVersion.cs
+++ b/SharedVersion.cs
@@ -1,4 +1,4 @@
using System.Reflection;
-[assembly: AssemblyVersion("10.9.0")]
-[assembly: AssemblyFileVersion("10.9.0")]
+[assembly: AssemblyVersion("10.10.0")]
+[assembly: AssemblyFileVersion("10.10.0")]
diff --git a/jellyfin.ruleset b/jellyfin.ruleset
index 10225e3af..db116f46c 100644
--- a/jellyfin.ruleset
+++ b/jellyfin.ruleset
@@ -85,6 +85,8 @@
<Rule Id="CA1309" Action="Error" />
<!-- error on CA1310: Specify StringComparison for correctness -->
<Rule Id="CA1310" Action="Error" />
+ <!-- error on CA1513: Use 'ObjectDisposedException.ThrowIf' instead of explicitly throwing a new exception instance -->
+ <Rule Id="CA1513" Action="Error" />
<!-- error on CA1725: Parameter names should match base declaration -->
<Rule Id="CA1725" Action="Error" />
<!-- error on CA1725: Call async methods when in an async method -->
@@ -101,6 +103,8 @@
<Rule Id="CA1849" Action="Error" />
<!-- error on CA1851: Possible multiple enumerations of IEnumerable collection -->
<Rule Id="CA1851" Action="Error" />
+ <!-- error on CA1854: Prefer a 'TryGetValue' call over a Dictionary indexer access guarded by a 'ContainsKey' check to avoid double lookup -->
+ <Rule Id="CA1854" Action="Error" />
<!-- error on CA2016: Forward the CancellationToken parameter to methods that take one
or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token -->
<Rule Id="CA2016" Action="Error" />
@@ -108,6 +112,8 @@
<Rule Id="CA2201" Action="Error" />
<!-- error on CA2215: Dispose methods should call base class dispose -->
<Rule Id="CA2215" Action="Error" />
+ <!-- error on CA2249: Use 'string.Contains' instead of 'string.IndexOf' to improve readability -->
+ <Rule Id="CA2249" Action="Error" />
<!-- error on CA2254: Template should be a static expression -->
<Rule Id="CA2254" Action="Error" />
diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
index a158e5c86..ede93aaa5 100644
--- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
+++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs
@@ -219,7 +219,7 @@ public class SkiaEncoder : IImageEncoder
return path;
}
- var tempPath = Path.Combine(_appPaths.TempDirectory, string.Concat(Guid.NewGuid().ToString(), Path.GetExtension(path.AsSpan())));
+ var tempPath = Path.Join(_appPaths.TempDirectory, string.Concat("skia_", Guid.NewGuid().ToString(), Path.GetExtension(path.AsSpan())));
var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid.");
Directory.CreateDirectory(directory);
File.Copy(path, tempPath, true);
@@ -263,6 +263,11 @@ public class SkiaEncoder : IImageEncoder
return null;
}
+ if (codec.FrameCount != 0)
+ {
+ throw new ArgumentException("Cannot decode images with multiple frames");
+ }
+
// create the bitmap
var bitmap = new SKBitmap(codec.Info.Width, codec.Info.Height, !requiresTransparencyHack);
@@ -554,9 +559,13 @@ public class SkiaEncoder : IImageEncoder
/// <inheritdoc />
public void CreateSplashscreen(IReadOnlyList<string> posters, IReadOnlyList<string> backdrops)
{
- var splashBuilder = new SplashscreenBuilder(this);
- var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
- splashBuilder.GenerateSplash(posters, backdrops, outputPath);
+ // Only generate the splash screen if we have at least one poster and at least one backdrop/thumbnail.
+ if (posters.Count > 0 && backdrops.Count > 0)
+ {
+ var splashBuilder = new SplashscreenBuilder(this);
+ var outputPath = Path.Combine(_appPaths.DataPath, "splashscreen.png");
+ splashBuilder.GenerateSplash(posters, backdrops, outputPath);
+ }
}
/// <inheritdoc />
diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
index 98b567e30..f786cc3b4 100644
--- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
+++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
@@ -8,12 +8,14 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
+ <!-- ICU4N.Transliterator only has prerelease versions -->
+ <NoWarn>NU5104</NoWarn>
</PropertyGroup>
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Extensions</PackageId>
- <VersionPrefix>10.9.0</VersionPrefix>
+ <VersionPrefix>10.10.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs
deleted file mode 100644
index cd582ced6..000000000
--- a/src/Jellyfin.Extensions/Json/Converters/JsonLowerCaseConverter.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace Jellyfin.Extensions.Json.Converters
-{
- /// <summary>
- /// Converts an object to a lowercase string.
- /// </summary>
- /// <typeparam name="T">The object type.</typeparam>
- public class JsonLowerCaseConverter<T> : JsonConverter<T>
- {
- /// <inheritdoc />
- public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- return JsonSerializer.Deserialize<T>(ref reader, options);
- }
-
- /// <inheritdoc />
- public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
- {
- writer.WriteStringValue(value?.ToString()?.ToLowerInvariant());
- }
- }
-}
diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs
index 182996852..0cfac384e 100644
--- a/src/Jellyfin.Extensions/StreamExtensions.cs
+++ b/src/Jellyfin.Extensions/StreamExtensions.cs
@@ -1,7 +1,9 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Runtime.CompilerServices;
using System.Text;
+using System.Threading;
namespace Jellyfin.Extensions
{
@@ -48,11 +50,12 @@ namespace Jellyfin.Extensions
/// Reads all lines in the <see cref="TextReader" />.
/// </summary>
/// <param name="reader">The <see cref="TextReader" /> to read from.</param>
+ /// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
/// <returns>All lines in the stream.</returns>
- public static async IAsyncEnumerable<string> ReadAllLinesAsync(this TextReader reader)
+ public static async IAsyncEnumerable<string> ReadAllLinesAsync(this TextReader reader, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string? line;
- while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null)
+ while ((line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false)) is not null)
{
yield return line;
}
diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
index cce2911dc..83f68ab50 100644
--- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
+++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs
@@ -1130,7 +1130,7 @@ namespace Jellyfin.LiveTv.Channels
{
if (!item.Tags.Contains("livestream", StringComparison.OrdinalIgnoreCase))
{
- item.Tags = item.Tags.Concat(new[] { "livestream" }).ToArray();
+ item.Tags = [..item.Tags, "livestream"];
_logger.LogDebug("Forcing update due to Tags {0}", item.Name);
forceUpdate = true;
}
diff --git a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
index 87f47611e..dfd376092 100644
--- a/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
+++ b/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
@@ -60,14 +60,13 @@ public class ListingsManager : IListingsManager
var config = _config.GetLiveTvConfiguration();
- var list = config.ListingProviders.ToList();
- int index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+ var list = config.ListingProviders;
+ int index = Array.FindIndex(list, i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
{
info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
- list.Add(info);
- config.ListingProviders = list.ToArray();
+ config.ListingProviders = [..list, info];
}
else
{
@@ -236,13 +235,12 @@ public class ListingsManager : IListingsManager
if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase))
{
- var list = listingsProviderInfo.ChannelMappings.ToList();
- list.Add(new NameValuePair
+ var newItem = new NameValuePair
{
Name = tunerChannelNumber,
Value = providerChannelNumber
- });
- listingsProviderInfo.ChannelMappings = list.ToArray();
+ };
+ listingsProviderInfo.ChannelMappings = [..listingsProviderInfo.ChannelMappings, newItem];
}
_config.SaveConfiguration("livetv", config);
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index c19d8195c..0c85dc434 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -939,7 +939,7 @@ namespace Jellyfin.LiveTv
{
var internalChannelId = _tvDtoService.GetInternalChannelId(i.Item2.Name, i.Item1.ChannelId);
var channel = _libraryManager.GetItemById(internalChannelId);
- channelName = channel is null ? null : channel.Name;
+ channelName = channel?.Name;
}
return _tvDtoService.GetSeriesTimerInfoDto(i.Item1, i.Item2, channelName);
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
index 92605a1eb..2f4caa386 100644
--- a/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingsManager.cs
@@ -159,7 +159,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
{
Locations = [customPath],
Name = "Recorded Movies",
- CollectionType = CollectionTypeOptions.Movies
+ CollectionType = CollectionTypeOptions.movies
};
}
@@ -172,7 +172,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
{
Locations = [customPath],
Name = "Recorded Shows",
- CollectionType = CollectionTypeOptions.TvShows
+ CollectionType = CollectionTypeOptions.tvshows
};
}
}
diff --git a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
index 18e4810a2..9e7323f5b 100644
--- a/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
+++ b/src/Jellyfin.LiveTv/Timers/ItemDataProvider.cs
@@ -115,11 +115,7 @@ namespace Jellyfin.LiveTv.Timers
throw new ArgumentException("item already exists", nameof(item));
}
- int oldLen = _items.Length;
- var newList = new T[oldLen + 1];
- _items.CopyTo(newList, 0);
- newList[oldLen] = item;
- _items = newList;
+ _items = [.._items, item];
SaveList();
}
@@ -134,11 +130,7 @@ namespace Jellyfin.LiveTv.Timers
int index = Array.FindIndex(_items, i => EqualityComparer(i, item));
if (index == -1)
{
- int oldLen = _items.Length;
- var newList = new T[oldLen + 1];
- _items.CopyTo(newList, 0);
- newList[oldLen] = item;
- _items = newList;
+ _items = [.._items, item];
}
else
{
diff --git a/src/Jellyfin.LiveTv/Timers/TimerManager.cs b/src/Jellyfin.LiveTv/Timers/TimerManager.cs
index da5deea36..1cf335159 100644
--- a/src/Jellyfin.LiveTv/Timers/TimerManager.cs
+++ b/src/Jellyfin.LiveTv/Timers/TimerManager.cs
@@ -22,7 +22,7 @@ namespace Jellyfin.LiveTv.Timers
public TimerManager(ILogger<TimerManager> logger, IConfigurationManager config)
: base(
logger,
- Path.Combine(config.CommonApplicationPaths.DataPath, "livetv"),
+ Path.Combine(config.CommonApplicationPaths.DataPath, "livetv/timers.json"),
(r1, r2) => string.Equals(r1.Id, r2.Id, StringComparison.OrdinalIgnoreCase))
{
}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs
index afc2e4f9c..aba9627ba 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/BaseTunerHost.cs
@@ -219,7 +219,7 @@ namespace Jellyfin.LiveTv.TunerHosts
}
}
- throw new LiveTvConflictException();
+ throw new LiveTvConflictException("Unable to find host to play channel");
}
protected virtual bool IsValidChannelId(string channelId)
diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
index 861338727..1dd35da41 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
@@ -145,7 +145,7 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun
}
_activeTuner = -1;
- throw new LiveTvConflictException();
+ throw new LiveTvConflictException("No tuners available");
}
public async Task ChangeChannel(IHdHomerunChannelCommands commands, CancellationToken cancellationToken)
diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
index 8d52151cb..365f0188d 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/M3UTunerHost.cs
@@ -5,7 +5,9 @@
using System;
using System.Collections.Generic;
using System.Globalization;
+using System.IO;
using System.Linq;
+using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@@ -28,15 +30,8 @@ namespace Jellyfin.LiveTv.TunerHosts
{
public class M3UTunerHost : BaseTunerHost, ITunerHost, IConfigurableTunerHost
{
- private static readonly string[] _disallowedMimeTypes =
- {
- "video/x-matroska",
- "video/mp4",
- "application/vnd.apple.mpegurl",
- "application/mpegurl",
- "application/x-mpegurl",
- "video/vnd.mpeg.dash.mpd"
- };
+ private static readonly string[] _mimeTypesCanShareHttpStream = ["video/MP2T"];
+ private static readonly string[] _extensionsCanShareHttpStream = [".ts", ".tsv", ".m2t"];
private readonly IHttpClientFactory _httpClientFactory;
private readonly IServerApplicationHost _appHost;
@@ -101,14 +96,31 @@ namespace Jellyfin.LiveTv.TunerHosts
if (mediaSource.Protocol == MediaProtocol.Http && !mediaSource.RequiresLooping)
{
- using var message = new HttpRequestMessage(HttpMethod.Head, mediaSource.Path);
- using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
- .SendAsync(message, cancellationToken)
- .ConfigureAwait(false);
-
- response.EnsureSuccessStatusCode();
+ var extension = Path.GetExtension(new UriBuilder(mediaSource.Path).Path);
- if (!_disallowedMimeTypes.Contains(response.Content.Headers.ContentType?.ToString(), StringComparison.OrdinalIgnoreCase))
+ if (string.IsNullOrEmpty(extension))
+ {
+ try
+ {
+ using var message = new HttpRequestMessage(HttpMethod.Head, mediaSource.Path);
+ using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .SendAsync(message, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (response.IsSuccessStatusCode)
+ {
+ if (_mimeTypesCanShareHttpStream.Contains(response.Content.Headers.ContentType?.MediaType, StringComparison.OrdinalIgnoreCase))
+ {
+ return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper);
+ }
+ }
+ }
+ catch (Exception)
+ {
+ Logger.LogWarning("HEAD request to check MIME type failed, shared stream disabled");
+ }
+ }
+ else if (_extensionsCanShareHttpStream.Contains(extension, StringComparison.OrdinalIgnoreCase))
{
return new SharedHttpStream(mediaSource, tunerHost, streamId, FileSystem, _httpClientFactory, Logger, Config, _appHost, _streamHelper);
}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs
index 5900d1c5b..c8d678e2f 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs
@@ -273,12 +273,12 @@ namespace Jellyfin.LiveTv.TunerHosts
var numberIndex = nameInExtInf.IndexOf(' ', StringComparison.Ordinal);
if (numberIndex > 0)
{
- var numberPart = nameInExtInf.Substring(0, numberIndex).Trim(new[] { ' ', '.' });
+ var numberPart = nameInExtInf.AsSpan(0, numberIndex).Trim(new[] { ' ', '.' });
if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
{
// channel.Number = number.ToString();
- nameInExtInf = nameInExtInf.Substring(numberIndex + 1).Trim(new[] { ' ', '-' });
+ nameInExtInf = nameInExtInf.AsSpan(numberIndex + 1).Trim(new[] { ' ', '-' }).ToString();
}
}
}
diff --git a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
index 60be19c68..473f00153 100644
--- a/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
+++ b/src/Jellyfin.LiveTv/TunerHosts/TunerHostManager.cs
@@ -76,14 +76,13 @@ public class TunerHostManager : ITunerHostManager
var config = _config.GetLiveTvConfiguration();
- var list = config.TunerHosts.ToList();
- var index = list.FindIndex(i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
+ var list = config.TunerHosts;
+ var index = Array.FindIndex(list, i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
{
info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
- list.Add(info);
- config.TunerHosts = list.ToArray();
+ config.TunerHosts = [..list, info];
}
else
{
diff --git a/src/Jellyfin.Networking/AutoDiscoveryHost.cs b/src/Jellyfin.Networking/AutoDiscoveryHost.cs
index 5624c4ed1..ff0d179b9 100644
--- a/src/Jellyfin.Networking/AutoDiscoveryHost.cs
+++ b/src/Jellyfin.Networking/AutoDiscoveryHost.cs
@@ -1,6 +1,4 @@
using System;
-using System.Collections.Generic;
-using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
@@ -59,65 +57,58 @@ public sealed class AutoDiscoveryHost : BackgroundService
return;
}
- var udpServers = new List<Task>();
- // Linux needs to bind to the broadcast addresses to receive broadcast traffic
- if (OperatingSystem.IsLinux() && networkConfig.EnableIPv4)
- {
- udpServers.Add(ListenForAutoDiscoveryMessage(IPAddress.Broadcast, stoppingToken));
- }
-
- udpServers.AddRange(_networkManager.GetInternalBindAddresses()
- .Select(intf => ListenForAutoDiscoveryMessage(
- OperatingSystem.IsLinux() && intf.AddressFamily == AddressFamily.InterNetwork
- ? NetworkUtils.GetBroadcastAddress(intf.Subnet)
- : intf.Address,
- stoppingToken)));
-
- await Task.WhenAll(udpServers).ConfigureAwait(false);
+ await ListenForAutoDiscoveryMessage(IPAddress.Any, stoppingToken).ConfigureAwait(false);
}
- private async Task ListenForAutoDiscoveryMessage(IPAddress address, CancellationToken cancellationToken)
+ private async Task ListenForAutoDiscoveryMessage(IPAddress listenAddress, CancellationToken cancellationToken)
{
- using var udpClient = new UdpClient(new IPEndPoint(address, PortNumber));
- udpClient.MulticastLoopback = false;
-
- while (!cancellationToken.IsCancellationRequested)
+ try
{
- try
+ using var udpClient = new UdpClient(new IPEndPoint(listenAddress, PortNumber));
+ udpClient.MulticastLoopback = false;
+
+ while (!cancellationToken.IsCancellationRequested)
{
- var result = await udpClient.ReceiveAsync(cancellationToken).ConfigureAwait(false);
- var text = Encoding.UTF8.GetString(result.Buffer);
- if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase))
+ try
{
- await RespondToV2Message(udpClient, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false);
+ var result = await udpClient.ReceiveAsync(cancellationToken).ConfigureAwait(false);
+ var text = Encoding.UTF8.GetString(result.Buffer);
+ if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase))
+ {
+ await RespondToV2Message(result.RemoteEndPoint, udpClient, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (SocketException ex)
+ {
+ _logger.LogError(ex, "Failed to receive data from socket");
}
- }
- catch (SocketException ex)
- {
- _logger.LogError(ex, "Failed to receive data from socket");
- }
- catch (OperationCanceledException)
- {
- _logger.LogDebug("Broadcast socket operation cancelled");
}
}
+ catch (OperationCanceledException)
+ {
+ _logger.LogDebug("Broadcast socket operation cancelled");
+ }
+ catch (Exception ex)
+ {
+ // Exception in this function will prevent the background service from restarting in-process.
+ _logger.LogError(ex, "Unable to bind to {Address}:{Port}", listenAddress, PortNumber);
+ }
}
- private async Task RespondToV2Message(UdpClient udpClient, IPEndPoint endpoint, CancellationToken cancellationToken)
+ private async Task RespondToV2Message(IPEndPoint endpoint, UdpClient broadCastUdpClient, CancellationToken cancellationToken)
{
var localUrl = _appHost.GetSmartApiUrl(endpoint.Address);
if (string.IsNullOrEmpty(localUrl))
{
- _logger.LogWarning("Unable to respond to server discovery request because the local ip address could not be determined.");
+ _logger.LogWarning("Unable to respond to server discovery request because the local ip address could not be determined");
return;
}
var response = new ServerDiscoveryInfo(localUrl, _appHost.SystemId, _appHost.FriendlyName);
-
try
{
_logger.LogDebug("Sending AutoDiscovery response");
- await udpClient
+ await broadCastUdpClient
.SendAsync(JsonSerializer.SerializeToUtf8Bytes(response).AsMemory(), endpoint, cancellationToken)
.ConfigureAwait(false);
}
diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs
index 1da44b048..cf6a2cc55 100644
--- a/src/Jellyfin.Networking/Manager/NetworkManager.cs
+++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs
@@ -11,7 +11,6 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Model.Net;
using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
@@ -237,7 +236,7 @@ public class NetworkManager : INetworkManager, IDisposable
var mac = adapter.GetPhysicalAddress();
// Populate MAC list
- if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && PhysicalAddress.None.Equals(mac))
+ if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && !PhysicalAddress.None.Equals(mac))
{
macAddresses.Add(mac);
}
@@ -412,7 +411,9 @@ public class NetworkManager : INetworkManager, IDisposable
interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetworkV6);
}
- _interfaces = interfaces;
+ // Users may have complex networking configuration that multiple interfaces sharing the same IP address
+ // Only return one IP for binding, and let the OS handle the rest
+ _interfaces = interfaces.DistinctBy(iface => iface.Address).ToList();
}
}
@@ -737,7 +738,9 @@ public class NetworkManager : INetworkManager, IDisposable
/// <inheritdoc/>
public IReadOnlyList<IPData> GetAllBindInterfaces(bool individualInterfaces = false)
{
- if (_interfaces.Count > 0 || individualInterfaces)
+ var config = _configurationManager.GetNetworkConfiguration();
+ var localNetworkAddresses = config.LocalNetworkAddresses;
+ if ((localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0]) && _interfaces.Count > 0) || individualInterfaces)
{
return _interfaces;
}
@@ -900,15 +903,30 @@ public class NetworkManager : INetworkManager, IDisposable
return false;
}
+ /// <summary>
+ /// Get if the IPAddress is Link-local.
+ /// </summary>
+ /// <param name="address">The IP Address.</param>
+ /// <returns>Bool indicates if the address is link-local.</returns>
+ public bool IsLinkLocalAddress(IPAddress address)
+ {
+ ArgumentNullException.ThrowIfNull(address);
+ return NetworkConstants.IPv4RFC3927LinkLocal.Contains(address) || address.IsIPv6LinkLocal;
+ }
+
/// <inheritdoc/>
public bool IsInLocalNetwork(IPAddress address)
{
ArgumentNullException.ThrowIfNull(address);
- // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+ // Map IPv6 mapped IPv4 back to IPv4 (happens if Kestrel runs in dual-socket mode)
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
if ((TrustAllIPv6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
- || address.Equals(IPAddress.Loopback)
- || address.Equals(IPAddress.IPv6Loopback))
+ || IPAddress.IsLoopback(address))
{
return true;
}
@@ -1017,7 +1035,7 @@ public class NetworkManager : INetworkManager, IDisposable
result = string.Empty;
int count = _interfaces.Count;
- if (count == 1 && (_interfaces[0].Equals(IPAddress.Any) || _interfaces[0].Equals(IPAddress.IPv6Any)))
+ if (count == 1 && (_interfaces[0].Address.Equals(IPAddress.Any) || _interfaces[0].Address.Equals(IPAddress.IPv6Any)))
{
// Ignore IPAny addresses.
count = 0;
@@ -1049,7 +1067,7 @@ public class NetworkManager : INetworkManager, IDisposable
return true;
}
- _logger.LogWarning("{Source}: External request received, no matching external bind address found, trying internal addresses.", source);
+ _logger.LogDebug("{Source}: External request received, no matching external bind address found, trying internal addresses", source);
}
else
{
@@ -1081,13 +1099,17 @@ public class NetworkManager : INetworkManager, IDisposable
private bool MatchesExternalInterface(IPAddress source, out string result)
{
// Get the first external interface address that isn't a loopback.
- var extResult = _interfaces.Where(p => !IsInLocalNetwork(p.Address)).OrderBy(x => x.Index).ToArray();
+ var extResult = _interfaces
+ .Where(p => !IsInLocalNetwork(p.Address))
+ .Where(p => p.Address.AddressFamily.Equals(source.AddressFamily))
+ .Where(p => !IsLinkLocalAddress(p.Address))
+ .OrderBy(x => x.Index).ToArray();
// No external interface found
if (extResult.Length == 0)
{
result = string.Empty;
- _logger.LogWarning("{Source}: External request received, but no external interface found. Need to route through internal network.", source);
+ _logger.LogDebug("{Source}: External request received, but no external interface found. Need to route through internal network", source);
return false;
}
@@ -1114,12 +1136,13 @@ public class NetworkManager : INetworkManager, IDisposable
var logLevel = debug ? LogLevel.Debug : LogLevel.Information;
if (_logger.IsEnabled(logLevel))
{
- _logger.Log(logLevel, "Defined LAN addresses: {0}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
- _logger.Log(logLevel, "Defined LAN exclusions: {0}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
- _logger.Log(logLevel, "Using LAN addresses: {0}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength));
- _logger.Log(logLevel, "Using bind addresses: {0}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address));
- _logger.Log(logLevel, "Remote IP filter is {0}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist");
- _logger.Log(logLevel, "Filter list: {0}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength));
+ _logger.Log(logLevel, "Defined LAN subnets: {Subnets}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
+ _logger.Log(logLevel, "Defined LAN exclusions: {Subnets}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength));
+ _logger.Log(logLevel, "Used LAN subnets: {Subnets}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength));
+ _logger.Log(logLevel, "Filtered interface addresses: {Addresses}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address));
+ _logger.Log(logLevel, "Bind Addresses {Addresses}", GetAllBindInterfaces(false).OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address));
+ _logger.Log(logLevel, "Remote IP filter is {Type}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist");
+ _logger.Log(logLevel, "Filtered subnets: {Subnets}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength));
}
}
}
diff --git a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs
index 1ea1797ba..31d2b486b 100644
--- a/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Auth/FirstTimeSetupPolicy/FirstTimeSetupHandlerTests.cs
@@ -1,13 +1,19 @@
+using System;
using System.Collections.Generic;
+using System.Security.Claims;
using System.Threading.Tasks;
using AutoFixture;
using AutoFixture.AutoMoq;
+using Jellyfin.Api.Auth.DefaultAuthorizationPolicy;
using Jellyfin.Api.Auth.FirstTimeSetupPolicy;
using Jellyfin.Api.Constants;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
@@ -17,7 +23,9 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy
{
private readonly Mock<IConfigurationManager> _configurationManagerMock;
private readonly List<IAuthorizationRequirement> _requirements;
+ private readonly DefaultAuthorizationHandler _defaultAuthorizationHandler;
private readonly FirstTimeSetupHandler _firstTimeSetupHandler;
+ private readonly IAuthorizationService _authorizationService;
private readonly Mock<IUserManager> _userManagerMock;
private readonly Mock<IHttpContextAccessor> _httpContextAccessor;
@@ -30,6 +38,21 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy
_httpContextAccessor = fixture.Freeze<Mock<IHttpContextAccessor>>();
_firstTimeSetupHandler = fixture.Create<FirstTimeSetupHandler>();
+ _defaultAuthorizationHandler = fixture.Create<DefaultAuthorizationHandler>();
+
+ var services = new ServiceCollection();
+ services.AddAuthorizationCore();
+ services.AddLogging();
+ services.AddOptions();
+ services.AddSingleton<IAuthorizationHandler>(_defaultAuthorizationHandler);
+ services.AddSingleton<IAuthorizationHandler>(_firstTimeSetupHandler);
+ services.AddAuthorization(options =>
+ {
+ options.AddPolicy("FirstTime", policy => policy.Requirements.Add(new FirstTimeSetupRequirement()));
+ options.AddPolicy("FirstTimeNoAdmin", policy => policy.Requirements.Add(new FirstTimeSetupRequirement(false, false)));
+ options.AddPolicy("FirstTimeSchedule", policy => policy.Requirements.Add(new FirstTimeSetupRequirement(true, false)));
+ });
+ _authorizationService = services.BuildServiceProvider().GetRequiredService<IAuthorizationService>();
}
[Theory]
@@ -44,10 +67,9 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy
_httpContextAccessor,
userRole);
- var context = new AuthorizationHandlerContext(_requirements, claims, null);
+ var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTime");
- await _firstTimeSetupHandler.HandleAsync(context);
- Assert.True(context.HasSucceeded);
+ Assert.True(allowed.Succeeded);
}
[Theory]
@@ -62,10 +84,43 @@ namespace Jellyfin.Api.Tests.Auth.FirstTimeSetupPolicy
_httpContextAccessor,
userRole);
- var context = new AuthorizationHandlerContext(_requirements, claims, null);
+ var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTime");
+
+ Assert.Equal(shouldSucceed, allowed.Succeeded);
+ }
+
+ [Theory]
+ [InlineData(UserRoles.Administrator, true)]
+ [InlineData(UserRoles.Guest, false)]
+ [InlineData(UserRoles.User, true)]
+ public async Task ShouldRequireUserIfNotAdministrator(string userRole, bool shouldSucceed)
+ {
+ TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
+ var claims = TestHelpers.SetupUser(
+ _userManagerMock,
+ _httpContextAccessor,
+ userRole);
+
+ var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTimeNoAdmin");
+
+ Assert.Equal(shouldSucceed, allowed.Succeeded);
+ }
+
+ [Fact]
+ public async Task ShouldDisallowUserIfOutsideSchedule()
+ {
+ AccessSchedule[] accessSchedules = { new AccessSchedule(DynamicDayOfWeek.Everyday, 0, 0, Guid.Empty) };
+
+ TestHelpers.SetupConfigurationManager(_configurationManagerMock, true);
+ var claims = TestHelpers.SetupUser(
+ _userManagerMock,
+ _httpContextAccessor,
+ UserRoles.User,
+ accessSchedules);
+
+ var allowed = await _authorizationService.AuthorizeAsync(claims, "FirstTimeSchedule");
- await _firstTimeSetupHandler.HandleAsync(context);
- Assert.Equal(shouldSucceed, context.HasSucceeded);
+ Assert.False(allowed.Succeeded);
}
}
}
diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
index 46439aecb..83a023384 100644
--- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
+++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
@@ -80,6 +80,21 @@ namespace Jellyfin.Controller.Tests
}
[Fact]
+ public void GetDirectories_GivenPathsWithDifferentCasing_ReturnsCorrectDirectories()
+ {
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ var upperCaseResult = directoryService.GetDirectories(UpperCasePath);
+ var lowerCaseResult = directoryService.GetDirectories(LowerCasePath);
+
+ Assert.Equal(_upperCaseFileSystemMetadata.Where(f => f.IsDirectory), upperCaseResult);
+ Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => f.IsDirectory), lowerCaseResult);
+ }
+
+ [Fact]
public void GetFile_GivenFilePathsWithDifferentCasing_ReturnsCorrectFile()
{
const string lowerCasePath = "/music/someartist/song 1.mp3";
@@ -95,15 +110,52 @@ namespace Jellyfin.Controller.Tests
Exists = false
};
var fileSystemMock = new Mock<IFileSystem>();
- fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == upperCasePath))).Returns(upperCaseFileSystemMetadata);
- fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == lowerCasePath))).Returns(lowerCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == upperCasePath))).Returns(upperCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == lowerCasePath))).Returns(lowerCaseFileSystemMetadata);
+ var directoryService = new DirectoryService(fileSystemMock.Object);
+
+ var lowerCaseDirResult = directoryService.GetDirectory(lowerCasePath);
+ var lowerCaseFileResult = directoryService.GetFile(lowerCasePath);
+ var upperCaseDirResult = directoryService.GetDirectory(upperCasePath);
+ var upperCaseFileResult = directoryService.GetFile(upperCasePath);
+
+ Assert.Null(lowerCaseDirResult);
+ Assert.Equal(lowerCaseFileSystemMetadata, lowerCaseFileResult);
+ Assert.Null(upperCaseDirResult);
+ Assert.Null(upperCaseFileResult);
+ }
+
+ [Fact]
+ public void GetDirectory_GivenFilePathsWithDifferentCasing_ReturnsCorrectDirectory()
+ {
+ const string lowerCasePath = "/music/someartist/Lyrics";
+ var lowerCaseFileSystemMetadata = new FileSystemMetadata
+ {
+ FullName = lowerCasePath,
+ IsDirectory = true,
+ Exists = true
+ };
+ const string upperCasePath = "/music/SOMEARTIST/LYRICS";
+ var upperCaseFileSystemMetadata = new FileSystemMetadata
+ {
+ FullName = upperCasePath,
+ IsDirectory = true,
+ Exists = false
+ };
+ var fileSystemMock = new Mock<IFileSystem>();
+ fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == upperCasePath))).Returns(upperCaseFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == lowerCasePath))).Returns(lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
- var lowerCaseResult = directoryService.GetFile(lowerCasePath);
- var upperCaseResult = directoryService.GetFile(upperCasePath);
+ var lowerCaseDirResult = directoryService.GetDirectory(lowerCasePath);
+ var lowerCaseFileResult = directoryService.GetFile(lowerCasePath);
+ var upperCaseDirResult = directoryService.GetDirectory(upperCasePath);
+ var upperCaseFileResult = directoryService.GetFile(upperCasePath);
- Assert.Equal(lowerCaseFileSystemMetadata, lowerCaseResult);
- Assert.Null(upperCaseResult);
+ Assert.Equal(lowerCaseFileSystemMetadata, lowerCaseDirResult);
+ Assert.Null(lowerCaseFileResult);
+ Assert.Null(upperCaseDirResult);
+ Assert.Null(upperCaseFileResult);
}
[Fact]
@@ -122,11 +174,11 @@ namespace Jellyfin.Controller.Tests
};
var fileSystemMock = new Mock<IFileSystem>();
- fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == path))).Returns(cachedFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == path))).Returns(cachedFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
var result = directoryService.GetFile(path);
- fileSystemMock.Setup(f => f.GetFileInfo(It.Is<string>(x => x == path))).Returns(newFileSystemMetadata);
+ fileSystemMock.Setup(f => f.GetFileSystemInfo(It.Is<string>(x => x == path))).Returns(newFileSystemMetadata);
var secondResult = directoryService.GetFile(path);
Assert.Equal(cachedFileSystemMetadata, result);
diff --git a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs b/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs
deleted file mode 100644
index 16c69ca48..000000000
--- a/tests/Jellyfin.Extensions.Tests/Json/Converters/JsonLowerCaseConverterTests.cs
+++ /dev/null
@@ -1,71 +0,0 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using Jellyfin.Extensions.Json.Converters;
-using MediaBrowser.Model.Entities;
-using Xunit;
-
-namespace Jellyfin.Extensions.Tests.Json.Converters
-{
- public class JsonLowerCaseConverterTests
- {
- private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions()
- {
- Converters =
- {
- new JsonStringEnumConverter()
- }
- };
-
- [Theory]
- [InlineData(null, "{\"CollectionType\":null}")]
- [InlineData(CollectionTypeOptions.Movies, "{\"CollectionType\":\"movies\"}")]
- [InlineData(CollectionTypeOptions.MusicVideos, "{\"CollectionType\":\"musicvideos\"}")]
- public void Serialize_CollectionTypeOptions_Correct(CollectionTypeOptions? collectionType, string expected)
- {
- Assert.Equal(expected, JsonSerializer.Serialize(new TestContainer(collectionType), _jsonOptions));
- }
-
- [Theory]
- [InlineData("{\"CollectionType\":null}", null)]
- [InlineData("{\"CollectionType\":\"movies\"}", CollectionTypeOptions.Movies)]
- [InlineData("{\"CollectionType\":\"musicvideos\"}", CollectionTypeOptions.MusicVideos)]
- public void Deserialize_CollectionTypeOptions_Correct(string json, CollectionTypeOptions? result)
- {
- var res = JsonSerializer.Deserialize<TestContainer>(json, _jsonOptions);
- Assert.NotNull(res);
- Assert.Equal(result, res!.CollectionType);
- }
-
- [Theory]
- [InlineData(null)]
- [InlineData(CollectionTypeOptions.Movies)]
- [InlineData(CollectionTypeOptions.MusicVideos)]
- public void RoundTrip_CollectionTypeOptions_Correct(CollectionTypeOptions? value)
- {
- var res = JsonSerializer.Deserialize<TestContainer>(JsonSerializer.Serialize(new TestContainer(value), _jsonOptions), _jsonOptions);
- Assert.NotNull(res);
- Assert.Equal(value, res!.CollectionType);
- }
-
- [Theory]
- [InlineData("{\"CollectionType\":null}")]
- [InlineData("{\"CollectionType\":\"movies\"}")]
- [InlineData("{\"CollectionType\":\"musicvideos\"}")]
- public void RoundTrip_String_Correct(string json)
- {
- var res = JsonSerializer.Serialize(JsonSerializer.Deserialize<TestContainer>(json, _jsonOptions), _jsonOptions);
- Assert.Equal(json, res);
- }
-
- private sealed class TestContainer
- {
- public TestContainer(CollectionTypeOptions? collectionType)
- {
- CollectionType = collectionType;
- }
-
- [JsonConverter(typeof(JsonLowerCaseConverter<CollectionTypeOptions?>))]
- public CollectionTypeOptions? CollectionType { get; set; }
- }
- }
-}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs
index 263f74c90..84008cffd 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeExternalSourcesTests.cs
@@ -35,7 +35,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Protocol = MediaProtocol.Http,
RequiredHttpHeaders = new Dictionary<string, string>()
{
- { "user_agent", userAgent },
+ { "User-Agent", userAgent },
}
},
ExtractChapters = false,
@@ -44,7 +44,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
var extraArg = encoder.GetExtraArguments(req);
- Assert.Contains(userAgent, extraArg, StringComparison.InvariantCulture);
+ Assert.Contains($"-user_agent \"{userAgent}\"", extraArg, StringComparison.InvariantCulture);
}
}
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
index 1becf07f5..df51d39cb 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
@@ -18,7 +18,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
public class ProbeResultNormalizerTests
{
private readonly JsonSerializerOptions _jsonOptions;
- private readonly ProbeResultNormalizer _probeResultNormalizer = new ProbeResultNormalizer(new NullLogger<EncoderValidatorTests>(), null);
+ private readonly ProbeResultNormalizer _probeResultNormalizer = new ProbeResultNormalizer(new NullLogger<EncoderValidatorTests>(), new Mock<ILocalizationManager>().Object);
public ProbeResultNormalizerTests()
{
diff --git a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs
index 2a62ab74c..a6f416414 100644
--- a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs
+++ b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs
@@ -141,7 +141,7 @@ namespace Jellyfin.Model.Tests.Entities
public void SetProviderId_Null_Remove()
{
var provider = new ProviderIdsExtensionsTestsObject();
- provider.SetProviderId(MetadataProvider.Imdb, null!);
+ Assert.Throws<ArgumentNullException>(() => provider.SetProviderId(MetadataProvider.Imdb, null!));
Assert.Empty(provider.ProviderIds);
}
@@ -150,8 +150,8 @@ namespace Jellyfin.Model.Tests.Entities
{
var provider = new ProviderIdsExtensionsTestsObject();
provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId;
- provider.SetProviderId(MetadataProvider.Imdb, string.Empty);
- Assert.Empty(provider.ProviderIds);
+ Assert.Throws<ArgumentException>(() => provider.SetProviderId(MetadataProvider.Imdb, string.Empty));
+ Assert.Single(provider.ProviderIds);
}
[Fact]
@@ -182,10 +182,20 @@ namespace Jellyfin.Model.Tests.Entities
ProviderIds = null!
};
- nullProvider.SetProviderId(MetadataProvider.Imdb, string.Empty);
+ Assert.Throws<ArgumentException>(() => nullProvider.SetProviderId(MetadataProvider.Imdb, string.Empty));
Assert.Null(nullProvider.ProviderIds);
}
+ [Fact]
+ public void RemoveProviderId_Null_Remove()
+ {
+ var provider = new ProviderIdsExtensionsTestsObject();
+
+ provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId;
+ provider.RemoveProviderId(MetadataProvider.Imdb);
+ Assert.Empty(provider.ProviderIds);
+ }
+
private sealed class ProviderIdsExtensionsTestsObject : IHasProviderIds
{
public static readonly ProviderIdsExtensionsTestsObject Empty = new ProviderIdsExtensionsTestsObject();
diff --git a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs
index ba602b5d2..0b8b1f644 100644
--- a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs
+++ b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs
@@ -104,6 +104,7 @@ public class ExternalPathParserTests
[InlineData(".en.cc.title", "title", "eng", false, false, true)]
[InlineData(".hi.en.title", "title", "eng", false, false, true)]
[InlineData(".en.hi.title", "title", "eng", false, false, true)]
+ [InlineData(".Subs for Chinese Audio.eng", "Subs for Chinese Audio", "eng", false, false, false)]
public void ParseFile_ExtraTokens_ParseToValues(string tokens, string? title, string? language, bool isDefault = false, bool isForced = false, bool isHearingImpaired = false)
{
var path = "My.Video" + tokens + ".srt";
diff --git a/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs b/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs
new file mode 100644
index 000000000..2d4b5b730
--- /dev/null
+++ b/tests/Jellyfin.Naming.Tests/TV/TvParserHelpersTest.cs
@@ -0,0 +1,31 @@
+using Emby.Naming.TV;
+using MediaBrowser.Model.Entities;
+using Xunit;
+
+namespace Jellyfin.Naming.Tests.TV;
+
+public class TvParserHelpersTest
+{
+ [Theory]
+ [InlineData("Ended", SeriesStatus.Ended)]
+ [InlineData("Cancelled", SeriesStatus.Ended)]
+ [InlineData("Continuing", SeriesStatus.Continuing)]
+ [InlineData("Returning", SeriesStatus.Continuing)]
+ [InlineData("Returning Series", SeriesStatus.Continuing)]
+ [InlineData("Unreleased", SeriesStatus.Unreleased)]
+ public void SeriesStatusParserTest_Valid(string statusString, SeriesStatus? status)
+ {
+ var successful = TvParserHelpers.TryParseSeriesStatus(statusString, out var parsered);
+ Assert.True(successful);
+ Assert.Equal(status, parsered);
+ }
+
+ [Theory]
+ [InlineData("XXX")]
+ public void SeriesStatusParserTest_InValid(string statusString)
+ {
+ var successful = TvParserHelpers.TryParseSeriesStatus(statusString, out var parsered);
+ Assert.False(successful);
+ Assert.Null(parsered);
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
index be5a401b1..5dd3eb8ab 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs
@@ -209,7 +209,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(ImageType.Backdrop, 2, false)]
[InlineData(ImageType.Primary, 1, true)]
[InlineData(ImageType.Backdrop, 2, true)]
- public async void RefreshImages_PopulatedItemPopulatedProviderDynamic_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
+ public async Task RefreshImages_PopulatedItemPopulatedProviderDynamic_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
{
var item = GetItemWithImages(imageType, imageCount, false);
@@ -261,7 +261,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(ImageType.Backdrop, 2, true, MediaProtocol.File)]
[InlineData(ImageType.Primary, 1, false, MediaProtocol.File)]
[InlineData(ImageType.Backdrop, 2, false, MediaProtocol.File)]
- public async void RefreshImages_EmptyItemPopulatedProviderDynamic_AddsImages(ImageType imageType, int imageCount, bool responseHasPath, MediaProtocol protocol)
+ public async Task RefreshImages_EmptyItemPopulatedProviderDynamic_AddsImages(ImageType imageType, int imageCount, bool responseHasPath, MediaProtocol protocol)
{
// Has to exist for querying DateModified time on file, results stored but not checked so not populating
BaseItem.FileSystem = Mock.Of<IFileSystem>();
@@ -311,7 +311,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(ImageType.Primary, 1, true)]
[InlineData(ImageType.Backdrop, 1, true)]
[InlineData(ImageType.Backdrop, 2, true)]
- public async void RefreshImages_PopulatedItemPopulatedProviderRemote_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
+ public async Task RefreshImages_PopulatedItemPopulatedProviderRemote_UpdatesImagesIfForced(ImageType imageType, int imageCount, bool forceRefresh)
{
var item = GetItemWithImages(imageType, imageCount, false);
@@ -366,7 +366,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(ImageType.Backdrop, 0, false)] // empty item, no cache to check
[InlineData(ImageType.Backdrop, 1, false)] // populated item, cached so no download
[InlineData(ImageType.Backdrop, 1, true)] // populated item, forced to download
- public async void RefreshImages_NonStubItemPopulatedProviderRemote_DownloadsIfNecessary(ImageType imageType, int initialImageCount, bool fullRefresh)
+ public async Task RefreshImages_NonStubItemPopulatedProviderRemote_DownloadsIfNecessary(ImageType imageType, int initialImageCount, bool fullRefresh)
{
var targetImageCount = 1;
@@ -429,7 +429,7 @@ namespace Jellyfin.Providers.Tests.Manager
[Theory]
[MemberData(nameof(GetImageTypesWithCount))]
- public async void RefreshImages_EmptyItemPopulatedProviderRemoteExtras_LimitsImages(ImageType imageType, int imageCount)
+ public async Task RefreshImages_EmptyItemPopulatedProviderRemoteExtras_LimitsImages(ImageType imageType, int imageCount)
{
var item = new Video();
@@ -473,7 +473,7 @@ namespace Jellyfin.Providers.Tests.Manager
[Theory]
[MemberData(nameof(GetImageTypesWithCount))]
- public async void RefreshImages_PopulatedItemEmptyProviderRemoteFullRefresh_DoesntClearImages(ImageType imageType, int imageCount)
+ public async Task RefreshImages_PopulatedItemEmptyProviderRemoteFullRefresh_DoesntClearImages(ImageType imageType, int imageCount)
{
var item = GetItemWithImages(imageType, imageCount, false);
@@ -501,7 +501,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(9, false)]
[InlineData(10, true)]
[InlineData(null, true)]
- public async void RefreshImages_ProviderRemote_FiltersByWidth(int? remoteImageWidth, bool expectedToUpdate)
+ public async Task RefreshImages_ProviderRemote_FiltersByWidth(int? remoteImageWidth, bool expectedToUpdate)
{
var imageType = ImageType.Primary;
@@ -575,18 +575,22 @@ namespace Jellyfin.Providers.Tests.Manager
// Has to exist for querying DateModified time on file, results stored but not checked so not populating
BaseItem.FileSystem ??= Mock.Of<IFileSystem>();
- var item = new Video();
+ var item = new Mock<Video>
+ {
+ CallBase = true
+ };
+ item.Setup(m => m.IsSaveLocalMetadataEnabled()).Returns(false);
var path = validPaths ? _testDataImagePath.Format : "invalid path {0}";
for (int i = 0; i < count; i++)
{
- item.SetImagePath(type, i, new FileSystemMetadata
+ item.Object.SetImagePath(type, i, new FileSystemMetadata
{
FullName = string.Format(CultureInfo.InvariantCulture, path, i),
});
}
- return item;
+ return item.Object;
}
private static ILocalImageProvider GetImageProvider(ImageType type, int count, bool validPaths)
diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs
index ec4df9981..cedcaf9c0 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
@@ -19,7 +20,7 @@ namespace Jellyfin.Providers.Tests.Manager
[InlineData(true, true)]
public void MergeBaseItemData_MergeMetadataSettings_MergesWhenSet(bool mergeMetadataSettings, bool defaultDate)
{
- var newLocked = new[] { MetadataField.Cast };
+ var newLocked = new[] { MetadataField.Genres, MetadataField.Cast };
var newString = "new";
var newDate = DateTime.Now;
@@ -77,7 +78,7 @@ namespace Jellyfin.Providers.Tests.Manager
[Theory]
[InlineData("Name", MetadataField.Name, false)]
- [InlineData("OriginalTitle", null, false)]
+ [InlineData("OriginalTitle", null)]
[InlineData("OfficialRating", MetadataField.OfficialRating)]
[InlineData("CustomRating")]
[InlineData("Tagline")]
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 6fccce049..cced2b1e2 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -585,15 +585,17 @@ namespace Jellyfin.Providers.Tests.Manager
IEnumerable<IMetadataService>? metadataServices = null,
IEnumerable<IMetadataProvider>? metadataProviders = null,
IEnumerable<IMetadataSaver>? metadataSavers = null,
- IEnumerable<IExternalId>? externalIds = null)
+ IEnumerable<IExternalId>? externalIds = null,
+ IEnumerable<IExternalUrlProvider>? externalUrlProviders = null)
{
imageProviders ??= Array.Empty<IImageProvider>();
metadataServices ??= Array.Empty<IMetadataService>();
metadataProviders ??= Array.Empty<IMetadataProvider>();
metadataSavers ??= Array.Empty<IMetadataSaver>();
externalIds ??= Array.Empty<IExternalId>();
+ externalUrlProviders ??= Array.Empty<IExternalUrlProvider>();
- providerManager.AddParts(imageProviders, metadataServices, metadataProviders, metadataSavers, externalIds);
+ providerManager.AddParts(imageProviders, metadataServices, metadataProviders, metadataSavers, externalIds, externalUrlProviders);
}
/// <summary>
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
index d5f6873a2..290cb817a 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/AudioResolverTests.cs
@@ -64,7 +64,7 @@ public class AudioResolverTests
[InlineData("My.Video.mp3", false, true)]
[InlineData("My.Video.srt", true, false)]
[InlineData("My.Video.mp3", true, true)]
- public async void GetExternalStreams_MixedFilenames_PicksAudio(string file, bool metadataDirectory, bool matches)
+ public async Task GetExternalStreams_MixedFilenames_PicksAudio(string file, bool metadataDirectory, bool matches)
{
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs
index 85963e5de..c0b41ba43 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs
@@ -37,7 +37,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
}
[Fact]
- public async void GetImage_NoStreams_ReturnsNoImage()
+ public async Task GetImage_NoStreams_ReturnsNoImage()
{
var input = new Movie();
@@ -55,7 +55,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[InlineData("clearlogo.png", null, 1, ImageType.Logo, ImageFormat.Png)] // extract extension from name
[InlineData("backdrop", "image/bmp", 2, ImageType.Backdrop, ImageFormat.Bmp)] // extract extension from mimetype
[InlineData("poster", null, 3, ImageType.Primary, ImageFormat.Jpg)] // default extension to jpg
- public async void GetImage_Attachment_ReturnsCorrectSelection(string filename, string? mimetype, int targetIndex, ImageType type, ImageFormat? expectedFormat)
+ public async Task GetImage_Attachment_ReturnsCorrectSelection(string filename, string? mimetype, int targetIndex, ImageType type, ImageFormat? expectedFormat)
{
var attachments = new List<MediaAttachment>();
string pathPrefix = "path";
@@ -103,7 +103,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[InlineData(null, "mjpeg", 1, ImageType.Primary, ImageFormat.Jpg)]
[InlineData(null, "png", 1, ImageType.Primary, ImageFormat.Png)]
[InlineData(null, "webp", 1, ImageType.Primary, ImageFormat.Webp)]
- public async void GetImage_Embedded_ReturnsCorrectSelection(string? label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat)
+ public async Task GetImage_Embedded_ReturnsCorrectSelection(string? label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat)
{
var streams = new List<MediaStream>();
for (int i = 1; i <= targetIndex; i++)
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
index 58b67ae55..db427308c 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
@@ -182,7 +182,7 @@ public class MediaInfoResolverTests
[Theory]
[InlineData("https://url.com/My.Video.mkv")]
[InlineData(VideoDirectoryPath)] // valid but no files found for this test
- public async void GetExternalStreams_BadPaths_ReturnsNoSubtitles(string path)
+ public async Task GetExternalStreams_BadPaths_ReturnsNoSubtitles(string path)
{
// need a media source manager capable of returning something other than file protocol
var mediaSourceManager = new Mock<IMediaSourceManager>();
@@ -285,7 +285,7 @@ public class MediaInfoResolverTests
[Theory]
[MemberData(nameof(GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly_Data))]
- public async void GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly(string file, MediaStream[] inputStreams, MediaStream[] expectedStreams)
+ public async Task GetExternalStreams_MergeMetadata_HandlesOverridesCorrectly(string file, MediaStream[] inputStreams, MediaStream[] expectedStreams)
{
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
@@ -335,7 +335,7 @@ public class MediaInfoResolverTests
[InlineData(1, 2)]
[InlineData(2, 1)]
[InlineData(2, 2)]
- public async void GetExternalStreams_StreamIndex_HandlesFilesAndContainers(int fileCount, int streamCount)
+ public async Task GetExternalStreams_StreamIndex_HandlesFilesAndContainers(int fileCount, int streamCount)
{
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs
index 8077bd791..e0d365927 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/SubtitleResolverTests.cs
@@ -64,7 +64,7 @@ public class SubtitleResolverTests
[InlineData("My.Video.mp3", false, false)]
[InlineData("My.Video.srt", true, true)]
[InlineData("My.Video.mp3", true, false)]
- public async void GetExternalStreams_MixedFilenames_PicksSubtitles(string file, bool metadataDirectory, bool matches)
+ public async Task GetExternalStreams_MixedFilenames_PicksSubtitles(string file, bool metadataDirectory, bool matches)
{
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs
index 7ea6f7d9c..028f6feba 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/VideoImageProviderTests.cs
@@ -34,7 +34,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[Theory]
[MemberData(nameof(GetImage_UnsupportedInput_ReturnsNoImage_TestData))]
- public async void GetImage_UnsupportedInput_ReturnsNoImage(Video input)
+ public async Task GetImage_UnsupportedInput_ReturnsNoImage(Video input)
{
var mediaSourceManager = GetMediaSourceManager(input, null, new List<MediaStream>());
var videoImageProvider = new VideoImageProvider(mediaSourceManager, Mock.Of<IMediaEncoder>(), new NullLogger<VideoImageProvider>());
@@ -47,7 +47,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[Theory]
[InlineData(1, 1)] // default not first stream
[InlineData(5, 0)] // default out of valid range
- public async void GetImage_DefaultVideoStreams_ReturnsCorrectStreamImage(int defaultIndex, int targetIndex)
+ public async Task GetImage_DefaultVideoStreams_ReturnsCorrectStreamImage(int defaultIndex, int targetIndex)
{
var input = new Movie { DefaultVideoStreamIndex = defaultIndex };
@@ -80,7 +80,7 @@ namespace Jellyfin.Providers.Tests.MediaInfo
[Theory]
[InlineData(null, 10)] // default time
[InlineData(500, 50)] // calculated time
- public async void GetImage_TimeSpan_SelectsCorrectTime(int? runTimeSeconds, long expectedSeconds)
+ public async Task GetImage_TimeSpan_SelectsCorrectTime(int? runTimeSeconds, long expectedSeconds)
{
MediaStream targetStream = new() { Type = MediaStreamType.Video, Index = 0 };
var input = new Movie
diff --git a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
index d991f5574..95a5b8179 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/IO/ManagedFileSystemTests.cs
@@ -20,26 +20,37 @@ namespace Jellyfin.Server.Implementations.Tests.IO
_sut = _fixture.Create<ManagedFileSystem>();
}
- [Theory]
+ [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")]
- public void MakeAbsolutePathCorrectlyHandlesRelativeFilePaths(
+ [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);
- if (OperatingSystem.IsWindows())
- {
- var expectedWindowsPath = expectedAbsolutePath.Replace('/', '\\');
- Assert.Equal(expectedWindowsPath, generatedPath.Split(':')[1]);
- }
- else
- {
- Assert.Equal(expectedAbsolutePath, generatedPath);
- }
+ Assert.Equal(expectedAbsolutePath, generatedPath);
}
[Theory]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
index d1be07aa2..940e3c2b1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs
@@ -18,6 +18,12 @@ namespace Jellyfin.Server.Implementations.Tests.Library
[InlineData("Superman: Red Son [tmdbid=618355][imdbid=tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid-618355][imdbid-tt10985510]", "imdbid", "tt10985510")]
[InlineData("Superman: Red Son [tmdbid-618355][imdbid-tt10985510]", "tmdbid", "618355")]
+ [InlineData("Superman: Red Son [providera-id=1]", "providera-id", "1")]
+ [InlineData("Superman: Red Son [providerb-id=2]", "providerb-id", "2")]
+ [InlineData("Superman: Red Son [providera id=4]", "providera id", "4")]
+ [InlineData("Superman: Red Son [providerb id=5]", "providerb id", "5")]
+ [InlineData("Superman: Red Son [tmdbid=3]", "tmdbid", "3")]
+ [InlineData("Superman: Red Son [tvdbid-6]", "tvdbid", "6")]
[InlineData("[tmdbid=618355]", "tmdbid", "618355")]
[InlineData("[tmdbid-618355]", "tmdbid", "618355")]
[InlineData("tmdbid=111111][tmdbid=618355]", "tmdbid", "618355")]
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 09e4709da..0a4a836cb 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -1,5 +1,6 @@
using System;
using System.Linq;
+using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Emby.Server.Implementations.Localization;
using MediaBrowser.Controller.Configuration;
@@ -127,6 +128,22 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
Assert.Equal(expectedLevel, level!);
}
+ [Theory]
+ [InlineData("0", 0)]
+ [InlineData("1", 1)]
+ [InlineData("6", 6)]
+ [InlineData("12", 12)]
+ [InlineData("42", 42)]
+ [InlineData("9999", 9999)]
+ public async Task GetRatingLevel_GivenValidAge_Success(string value, int expectedLevel)
+ {
+ var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" });
+ await localizationManager.LoadAll();
+ var level = localizationManager.GetRatingLevel(value);
+ Assert.NotNull(level);
+ Assert.Equal(expectedLevel, level);
+ }
+
[Fact]
public async Task GetRatingLevel_GivenUnratedString_Success()
{
@@ -142,6 +159,20 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Theory]
+ [InlineData("-NO RATING SHOWN-")]
+ [InlineData(":NO RATING SHOWN:")]
+ public async Task GetRatingLevel_Split_Success(string value)
+ {
+ var localizationManager = Setup(new ServerConfiguration()
+ {
+ UICulture = "en-US"
+ });
+ await localizationManager.LoadAll();
+
+ Assert.Null(localizationManager.GetRatingLevel(value));
+ }
+
+ [Theory]
[InlineData("Default", "Default")]
[InlineData("HeaderLiveTV", "Live TV")]
public void GetLocalizedString_Valid_Success(string key, string expected)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Sorting/PremiereDateComparerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Sorting/PremiereDateComparerTests.cs
new file mode 100644
index 000000000..9dfacb2bf
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Sorting/PremiereDateComparerTests.cs
@@ -0,0 +1,76 @@
+using System;
+using Emby.Server.Implementations.Sorting;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Sorting
+{
+ public class PremiereDateComparerTests
+ {
+ private readonly PremiereDateComparer _cmp = new PremiereDateComparer();
+
+ [Theory]
+ [ClassData(typeof(PremiereDateTestData))]
+ public void PremiereDateCompareTest(BaseItem x, BaseItem y, int expected)
+ {
+ Assert.Equal(expected, _cmp.Compare(x, y));
+ Assert.Equal(-expected, _cmp.Compare(y, x));
+ }
+
+ private sealed class PremiereDateTestData : TheoryData<BaseItem, BaseItem, int>
+ {
+ public PremiereDateTestData()
+ {
+ // Happy case - Both have premier date
+ // Expected: x listed first
+ Add(
+ new Movie { PremiereDate = new DateTime(2018, 1, 1) },
+ new Movie { PremiereDate = new DateTime(2018, 1, 3) },
+ -1);
+
+ // Both have premiere date, but y has invalid date
+ // Expected: y listed first
+ Add(
+ new Movie { PremiereDate = new DateTime(2019, 1, 1) },
+ new Movie { PremiereDate = new DateTime(03, 1, 1) },
+ 1);
+
+ // Only x has premiere date, with earlier year than y
+ // Expected: x listed first
+ Add(
+ new Movie { PremiereDate = new DateTime(2020, 1, 1) },
+ new Movie { ProductionYear = 2021 },
+ -1);
+
+ // Only x has premiere date, with same year as y
+ // Expected: y listed first
+ Add(
+ new Movie { PremiereDate = new DateTime(2022, 1, 2) },
+ new Movie { ProductionYear = 2022 },
+ 1);
+
+ // Only x has a premiere date, with later year than y
+ // Expected: y listed first
+ Add(
+ new Movie { PremiereDate = new DateTime(2024, 3, 1) },
+ new Movie { ProductionYear = 2023 },
+ 1);
+
+ // Only x has a premiere date, y has an invalid year
+ // Expected: y listed first
+ Add(
+ new Movie { PremiereDate = new DateTime(2025, 1, 1) },
+ new Movie { ProductionYear = 0 },
+ 1);
+
+ // Only x has a premiere date, y has neither date nor year
+ // Expected: y listed first
+ Add(
+ new Movie { PremiereDate = new DateTime(2026, 1, 1) },
+ new Movie(),
+ 1);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
new file mode 100644
index 000000000..bf3bfdad4
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Jellyfin.Api.Models.LibraryStructureDto;
+using Jellyfin.Extensions.Json;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Xunit;
+using Xunit.Priority;
+
+namespace Jellyfin.Server.Integration.Tests.Controllers;
+
+[TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)]
+public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinApplicationFactory>
+{
+ private readonly JellyfinApplicationFactory _factory;
+ private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
+ private static string? _accessToken;
+
+ public LibraryStructureControllerTests(JellyfinApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Fact]
+ [Priority(-1)]
+ public async Task Post_NewVirtualFolder_NotFound()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ var body = new AddVirtualFolderDto()
+ {
+ LibraryOptions = new LibraryOptions()
+ {
+ Enabled = false
+ }
+ };
+
+ using var response = await client.PostAsJsonAsync("Library/VirtualFolders?name=test&refreshLibrary=true", body, _jsonOptions);
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ }
+
+ [Fact]
+ [Priority(0)]
+ public async Task UpdateLibraryOptions_Invalid_NotFound()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ var body = new UpdateLibraryOptionsDto()
+ {
+ Id = Guid.NewGuid(),
+ LibraryOptions = new LibraryOptions()
+ };
+
+ using var response = await client.PostAsJsonAsync("Library/VirtualFolders/LibraryOptions", body, _jsonOptions);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ [Priority(0)]
+ public async Task UpdateLibraryOptions_Valid_Success()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.GetAsync("Library/VirtualFolders");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var library = await response.Content.ReadFromJsonAsAsyncEnumerable<VirtualFolderInfo>(_jsonOptions)
+ .FirstOrDefaultAsync(x => string.Equals(x?.Name, "test", StringComparison.Ordinal));
+ Assert.NotNull(library);
+
+ var options = library.LibraryOptions;
+ Assert.NotNull(options);
+ Assert.False(options.Enabled);
+ options.Enabled = true;
+
+ var body = new UpdateLibraryOptionsDto()
+ {
+ Id = Guid.Parse(library.ItemId),
+ LibraryOptions = options
+ };
+
+ using var response2 = await client.PostAsJsonAsync("Library/VirtualFolders/LibraryOptions", body, _jsonOptions);
+ Assert.Equal(HttpStatusCode.NoContent, response2.StatusCode);
+ }
+
+ [Fact]
+ [Priority(1)]
+ public async Task DeleteLibrary_Invalid_NotFound()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.DeleteAsync("Library/VirtualFolders?name=doesntExist");
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Fact]
+ [Priority(1)]
+ public async Task DeleteLibrary_Valid_Success()
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.DeleteAsync("Library/VirtualFolders?name=test&refreshLibrary=true");
+ Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
+ }
+}
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs
index b9def13f8..ab68884f9 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs
@@ -21,7 +21,7 @@ public class SessionControllerTests : IClassFixture<JellyfinApplicationFactory>
var client = _factory.CreateClient();
client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
- using var response = await client.GetAsync($"Session/Sessions?userId={Guid.NewGuid()}");
+ using var response = await client.GetAsync($"Sessions?controllableByUserId={Guid.NewGuid()}");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs
index 8019e0ab3..2f05c4ea2 100644
--- a/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Location/MovieNfoLocationTests.cs
@@ -47,6 +47,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Location
var movie = new Movie() { Path = "/media/movies/Avengers Endgame", VideoType = VideoType.Dvd };
var path1 = "/media/movies/Avengers Endgame/Avengers Endgame.nfo";
var path2 = "/media/movies/Avengers Endgame/VIDEO_TS/VIDEO_TS.nfo";
+ var path3 = "/media/movies/Avengers Endgame/movie.nfo";
// uses ContainingFolderPath which uses Operating system specific paths
if (OperatingSystem.IsWindows())
@@ -54,12 +55,14 @@ namespace Jellyfin.XbmcMetadata.Tests.Location
movie.Path = movie.Path.Replace('/', '\\');
path1 = path1.Replace('/', '\\');
path2 = path2.Replace('/', '\\');
+ path3 = path3.Replace('/', '\\');
}
var paths = MovieNfoSaver.GetMovieSavePaths(new ItemInfo(movie)).ToArray();
- Assert.Equal(2, paths.Length);
+ Assert.Equal(3, paths.Length);
Assert.Contains(path1, paths);
Assert.Contains(path2, paths);
+ Assert.Contains(path3, paths);
}
}
}