aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ci/azure-pipelines-api-client.yml3
-rw-r--r--.ci/azure-pipelines-package.yml6
-rw-r--r--.ci/azure-pipelines-test.yml4
-rw-r--r--.github/CODEOWNERS1
-rw-r--r--.github/workflows/codeql-analysis.yml36
-rw-r--r--CONTRIBUTORS.md2
-rw-r--r--Dockerfile23
-rw-r--r--Emby.Dlna/Common/Argument.cs2
-rw-r--r--Emby.Dlna/Configuration/DlnaOptions.cs69
-rw-r--r--Emby.Dlna/ConnectionManager/ConnectionManagerService.cs12
-rw-r--r--Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs145
-rw-r--r--Emby.Dlna/ConnectionManager/ControlHandler.cs13
-rw-r--r--Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs37
-rw-r--r--Emby.Dlna/ContentDirectory/ControlHandler.cs3
-rw-r--r--Emby.Dlna/DlnaManager.cs4
-rw-r--r--Emby.Dlna/Eventing/DlnaEventManager.cs3
-rw-r--r--Emby.Dlna/Main/DlnaEntryPoint.cs40
-rw-r--r--Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs1
-rw-r--r--Emby.Dlna/PlayTo/Device.cs14
-rw-r--r--Emby.Dlna/PlayTo/PlayToController.cs47
-rw-r--r--Emby.Dlna/PlayTo/PlayToManager.cs51
-rw-r--r--Emby.Dlna/PlayTo/PlaylistItemFactory.cs4
-rw-r--r--Emby.Dlna/PlayTo/SsdpHttpClient.cs7
-rw-r--r--Emby.Dlna/PlayTo/TransportCommands.cs16
-rw-r--r--Emby.Dlna/Properties/AssemblyInfo.cs2
-rw-r--r--Emby.Dlna/Server/DescriptionXmlBuilder.cs18
-rw-r--r--Emby.Naming/Subtitles/SubtitleParser.cs2
-rw-r--r--Emby.Naming/Video/VideoListResolver.cs8
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs328
-rw-r--r--Emby.Server.Implementations/Channels/ChannelManager.cs10
-rw-r--r--Emby.Server.Implementations/Data/SqliteExtensions.cs14
-rw-r--r--Emby.Server.Implementations/Data/SqliteItemRepository.cs52
-rw-r--r--Emby.Server.Implementations/Devices/DeviceManager.cs71
-rw-r--r--Emby.Server.Implementations/Emby.Server.Implementations.csproj5
-rw-r--r--Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs11
-rw-r--r--Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs7
-rw-r--r--Emby.Server.Implementations/HttpServer/Security/AuthService.cs9
-rw-r--r--Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs4
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketManager.cs41
-rw-r--r--Emby.Server.Implementations/IO/IsoManager.cs67
-rw-r--r--Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs130
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs61
-rw-r--r--Emby.Server.Implementations/Library/MediaStreamSelector.cs2
-rw-r--r--Emby.Server.Implementations/Library/SearchEngine.cs4
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs7
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs2
-rw-r--r--Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs13
-rw-r--r--Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs107
-rw-r--r--Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs2
-rw-r--r--Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs3
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs9
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs51
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs76
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs6
-rw-r--r--Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs4
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/es-MX.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/fa.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/fi.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr-CA.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/fr.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/he.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/id.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json19
-rw-r--r--Emby.Server.Implementations/Localization/Core/nl.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/pl.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt.json7
-rw-r--r--Emby.Server.Implementations/Localization/Core/ro.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/ru.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/sk.json33
-rw-r--r--Emby.Server.Implementations/Localization/Core/ta.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/tr.json6
-rw-r--r--Emby.Server.Implementations/Localization/Core/uk.json9
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json17
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-CN.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-TW.json5
-rw-r--r--Emby.Server.Implementations/Localization/countries.json6
-rw-r--r--Emby.Server.Implementations/MediaEncoder/EncodingManager.cs20
-rw-r--r--Emby.Server.Implementations/Networking/NetworkManager.cs556
-rw-r--r--Emby.Server.Implementations/Playlists/PlaylistManager.cs6
-rw-r--r--Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs2
-rw-r--r--Emby.Server.Implementations/ResourceFileManager.cs45
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/TaskManager.cs4
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs62
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs30
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs80
-rw-r--r--Emby.Server.Implementations/SyncPlay/Group.cs674
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayController.cs514
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs389
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs40
-rw-r--r--Emby.Server.Implementations/Udp/UdpServer.cs2
-rw-r--r--Emby.Server.Implementations/Updates/InstallationManager.cs134
-rw-r--r--Jellyfin.Api/Auth/CustomAuthenticationHandler.cs5
-rw-r--r--Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs58
-rw-r--r--Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs33
-rw-r--r--Jellyfin.Api/Constants/Policies.cs10
-rw-r--r--Jellyfin.Api/Controllers/ApiKeyController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ArtistsController.cs145
-rw-r--r--Jellyfin.Api/Controllers/AudioController.cs181
-rw-r--r--Jellyfin.Api/Controllers/BrandingController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ChannelsController.cs10
-rw-r--r--Jellyfin.Api/Controllers/CollectionController.cs19
-rw-r--r--Jellyfin.Api/Controllers/DashboardController.cs2
-rw-r--r--Jellyfin.Api/Controllers/DisplayPreferencesController.cs56
-rw-r--r--Jellyfin.Api/Controllers/DlnaServerController.cs2
-rw-r--r--Jellyfin.Api/Controllers/DynamicHlsController.cs295
-rw-r--r--Jellyfin.Api/Controllers/FilterController.cs76
-rw-r--r--Jellyfin.Api/Controllers/GenresController.cs20
-rw-r--r--Jellyfin.Api/Controllers/HlsSegmentController.cs28
-rw-r--r--Jellyfin.Api/Controllers/ImageController.cs696
-rw-r--r--Jellyfin.Api/Controllers/InstantMixController.cs6
-rw-r--r--Jellyfin.Api/Controllers/ItemLookupController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs2
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs460
-rw-r--r--Jellyfin.Api/Controllers/LibraryController.cs13
-rw-r--r--Jellyfin.Api/Controllers/LiveTvController.cs36
-rw-r--r--Jellyfin.Api/Controllers/LocalizationController.cs2
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs60
-rw-r--r--Jellyfin.Api/Controllers/MoviesController.cs6
-rw-r--r--Jellyfin.Api/Controllers/MusicGenresController.cs20
-rw-r--r--Jellyfin.Api/Controllers/PackageController.cs10
-rw-r--r--Jellyfin.Api/Controllers/PersonsController.cs14
-rw-r--r--Jellyfin.Api/Controllers/PlaylistsController.cs15
-rw-r--r--Jellyfin.Api/Controllers/PlaystateController.cs5
-rw-r--r--Jellyfin.Api/Controllers/PluginsController.cs2
-rw-r--r--Jellyfin.Api/Controllers/SearchController.cs15
-rw-r--r--Jellyfin.Api/Controllers/SessionController.cs13
-rw-r--r--Jellyfin.Api/Controllers/StartupController.cs14
-rw-r--r--Jellyfin.Api/Controllers/StudiosController.cs23
-rw-r--r--Jellyfin.Api/Controllers/SubtitleController.cs42
-rw-r--r--Jellyfin.Api/Controllers/SuggestionsController.cs11
-rw-r--r--Jellyfin.Api/Controllers/SyncPlayController.cs315
-rw-r--r--Jellyfin.Api/Controllers/SystemController.cs10
-rw-r--r--Jellyfin.Api/Controllers/TimeSyncController.cs16
-rw-r--r--Jellyfin.Api/Controllers/TrailersController.cs93
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs28
-rw-r--r--Jellyfin.Api/Controllers/UniversalAudioController.cs17
-rw-r--r--Jellyfin.Api/Controllers/UserController.cs2
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs8
-rw-r--r--Jellyfin.Api/Controllers/UserViewsController.cs9
-rw-r--r--Jellyfin.Api/Controllers/VideoAttachmentsController.cs3
-rw-r--r--Jellyfin.Api/Controllers/VideoHlsController.cs183
-rw-r--r--Jellyfin.Api/Controllers/VideosController.cs182
-rw-r--r--Jellyfin.Api/Controllers/YearsController.cs29
-rw-r--r--Jellyfin.Api/Helpers/ClassMigrationHelper.cs71
-rw-r--r--Jellyfin.Api/Helpers/DynamicHlsHelper.cs198
-rw-r--r--Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs6
-rw-r--r--Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs92
-rw-r--r--Jellyfin.Api/Helpers/HlsHelpers.cs57
-rw-r--r--Jellyfin.Api/Helpers/RequestHelpers.cs43
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs53
-rw-r--r--Jellyfin.Api/Helpers/TranscodingJobHelper.cs55
-rw-r--r--Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs49
-rw-r--r--Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs47
-rw-r--r--Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs27
-rw-r--r--Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs90
-rw-r--r--Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs9
-rw-r--r--Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs86
-rw-r--r--Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs6
-rw-r--r--Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs88
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs42
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs14
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs16
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs30
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs22
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs24
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs14
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs37
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs24
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs32
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs42
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs25
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs14
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs24
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs16
-rw-r--r--Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs16
-rw-r--r--Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs15
-rw-r--r--Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs44
-rw-r--r--Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs2
-rw-r--r--Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs90
-rw-r--r--Jellyfin.Data/Entities/DisplayPreferences.cs12
-rw-r--r--Jellyfin.Data/Entities/Libraries/CollectionItem.cs4
-rw-r--r--Jellyfin.Data/Entities/Libraries/ItemMetadata.cs2
-rw-r--r--Jellyfin.Data/Jellyfin.Data.csproj2
-rw-r--r--Jellyfin.Networking/Configuration/NetworkConfiguration.cs221
-rw-r--r--Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs21
-rw-r--r--Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs27
-rw-r--r--Jellyfin.Networking/Jellyfin.Networking.csproj30
-rw-r--r--Jellyfin.Networking/Manager/NetworkManager.cs1323
-rw-r--r--Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs8
-rw-r--r--Jellyfin.Server.Implementations/JellyfinDb.cs14
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs522
-rw-r--r--Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs108
-rw-r--r--Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs139
-rw-r--r--Jellyfin.Server.Implementations/ModelBuilderExtensions.cs48
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs2
-rw-r--r--Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs34
-rw-r--r--Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs21
-rw-r--r--Jellyfin.Server/CoreAppHost.cs17
-rw-r--r--Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs5
-rw-r--r--Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs74
-rw-r--r--Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs78
-rw-r--r--Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs3
-rw-r--r--Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs41
-rw-r--r--Jellyfin.Server/Middleware/LanFilteringMiddleware.cs38
-rw-r--r--Jellyfin.Server/Migrations/MigrationRunner.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs49
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs26
-rw-r--r--Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs4
-rw-r--r--Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs8
-rw-r--r--Jellyfin.Server/Program.cs70
-rw-r--r--Jellyfin.Server/Startup.cs26
-rw-r--r--MediaBrowser.Common/Cryptography/PasswordHash.cs10
-rw-r--r--MediaBrowser.Common/Hex.cs94
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs3
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs17
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs75
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs28
-rw-r--r--MediaBrowser.Common/Json/Converters/JsonVersionConverter.cs20
-rw-r--r--MediaBrowser.Common/Json/JsonDefaults.cs1
-rw-r--r--MediaBrowser.Common/MediaBrowser.Common.csproj2
-rw-r--r--MediaBrowser.Common/Net/INetworkManager.cs234
-rw-r--r--MediaBrowser.Common/Net/IPHost.cs445
-rw-r--r--MediaBrowser.Common/Net/IPNetAddress.cs277
-rw-r--r--MediaBrowser.Common/Net/IPObject.cs406
-rw-r--r--MediaBrowser.Common/Net/NetworkExtensions.cs262
-rw-r--r--MediaBrowser.Common/Plugins/BasePlugin.cs21
-rw-r--r--MediaBrowser.Common/Plugins/LocalPlugin.cs10
-rw-r--r--MediaBrowser.Common/Updates/IInstallationManager.cs7
-rw-r--r--MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs135
-rw-r--r--MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs36
-rw-r--r--MediaBrowser.Controller/Channels/IChannelManager.cs2
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs57
-rw-r--r--MediaBrowser.Controller/Entities/Folder.cs228
-rw-r--r--MediaBrowser.Controller/Entities/InternalItemsQuery.cs6
-rw-r--r--MediaBrowser.Controller/Entities/UserViewBuilder.cs4
-rw-r--r--MediaBrowser.Controller/Entities/Video.cs45
-rw-r--r--MediaBrowser.Controller/IDisplayPreferencesManager.cs21
-rw-r--r--MediaBrowser.Controller/IResourceFileManager.cs9
-rw-r--r--MediaBrowser.Controller/IServerApplicationHost.cs44
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs4
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj1
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs764
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs15
-rw-r--r--MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs17
-rw-r--r--MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs33
-rw-r--r--MediaBrowser.Controller/MediaEncoding/MediaInfoRequest.cs11
-rw-r--r--MediaBrowser.Controller/Net/AuthorizationInfo.cs5
-rw-r--r--MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs3
-rw-r--r--MediaBrowser.Controller/Net/IWebSocketListener.cs9
-rw-r--r--MediaBrowser.Controller/Net/IWebSocketManager.cs8
-rw-r--r--MediaBrowser.Controller/Playlists/IPlaylistManager.cs2
-rw-r--r--MediaBrowser.Controller/Providers/IProviderManager.cs8
-rw-r--r--MediaBrowser.Controller/Session/ISessionManager.cs12
-rw-r--r--MediaBrowser.Controller/Session/SessionInfo.cs5
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupInfo.cs160
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupMember.cs27
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs222
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs126
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs165
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs168
-rw-r--r--MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs680
-rw-r--r--MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs27
-rw-r--r--MediaBrowser.Controller/SyncPlay/IGroupState.cs217
-rw-r--r--MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs222
-rw-r--r--MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs67
-rw-r--r--MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs34
-rw-r--r--MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs16
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs29
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs61
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs45
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs37
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs21
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs54
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs37
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs46
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs61
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs38
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs37
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs36
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs21
-rw-r--r--MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs21
-rw-r--r--MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs577
-rw-r--r--MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs29
-rw-r--r--MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs13
-rw-r--r--MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs13
-rw-r--r--MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs28
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs16
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs29
-rw-r--r--MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs182
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs121
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs16
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs57
-rw-r--r--MediaBrowser.Model/Configuration/EncodingOptions.cs3
-rw-r--r--MediaBrowser.Model/Configuration/PathSubstitution.cs20
-rw-r--r--MediaBrowser.Model/Configuration/ServerConfiguration.cs410
-rw-r--r--MediaBrowser.Model/Dlna/ContainerProfile.cs2
-rw-r--r--MediaBrowser.Model/Dlna/ResolutionNormalizer.cs12
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs72
-rw-r--r--MediaBrowser.Model/Dlna/StreamInfo.cs2
-rw-r--r--MediaBrowser.Model/IO/IIsoManager.cs35
-rw-r--r--MediaBrowser.Model/IO/IIsoMount.cs22
-rw-r--r--MediaBrowser.Model/IO/IIsoMounter.cs35
-rw-r--r--MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs8
-rw-r--r--MediaBrowser.Model/Querying/NextUpQuery.cs2
-rw-r--r--MediaBrowser.Model/Search/SearchQuery.cs2
-rw-r--r--MediaBrowser.Model/Session/ClientCapabilities.cs5
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupInfoDto.cs58
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupInfoView.cs42
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupQueueMode.cs18
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs23
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs18
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupStateType.cs28
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs31
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupUpdate.cs30
-rw-r--r--MediaBrowser.Model/SyncPlay/GroupUpdateType.cs8
-rw-r--r--MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs16
-rw-r--r--MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs74
-rw-r--r--MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs58
-rw-r--r--MediaBrowser.Model/SyncPlay/PlaybackRequest.cs34
-rw-r--r--MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs71
-rw-r--r--MediaBrowser.Model/SyncPlay/QueueItem.cs31
-rw-r--r--MediaBrowser.Model/SyncPlay/RequestType.cs33
-rw-r--r--MediaBrowser.Model/SyncPlay/SendCommand.cs45
-rw-r--r--MediaBrowser.Model/SyncPlay/SendCommandType.cs11
-rw-r--r--MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs28
-rw-r--r--MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs21
-rw-r--r--MediaBrowser.Model/Updates/PackageInfo.cs12
-rw-r--r--MediaBrowser.Model/Updates/RepositoryInfo.cs6
-rw-r--r--MediaBrowser.Model/Updates/VersionInfo.cs30
-rw-r--r--MediaBrowser.Providers/Manager/ItemImageProvider.cs4
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs13
-rw-r--r--MediaBrowser.Providers/Manager/ProviderManager.cs36
-rw-r--r--MediaBrowser.Providers/Manager/ProviderUtils.cs4
-rw-r--r--MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs7
-rw-r--r--MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs6
-rw-r--r--MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs26
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs9
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs9
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html14
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs18
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html16
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs33
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs2
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs16
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs10
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs29
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs289
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs130
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs262
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs113
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs155
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs153
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs419
-rw-r--r--MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs39
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs6
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs2
-rw-r--r--MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs28
-rw-r--r--MediaBrowser.Providers/TV/TvdbExternalId.cs28
-rw-r--r--MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs28
-rw-r--r--MediaBrowser.Providers/TV/Zap2ItExternalId.cs1
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs10
-rw-r--r--MediaBrowser.sln50
-rw-r--r--RSSDP/DisposableManagedObjectBase.cs6
-rw-r--r--RSSDP/HttpParserBase.cs2
-rw-r--r--RSSDP/ISsdpDeviceLocator.cs4
-rw-r--r--RSSDP/RSSDP.csproj1
-rw-r--r--RSSDP/SsdpCommunicationsServer.cs4
-rw-r--r--RSSDP/SsdpDevicePublisher.cs14
-rw-r--r--RSSDP/SsdpRootDevice.cs8
-rw-r--r--benches/Jellyfin.Common.Benches/HexDecodeBenches.cs45
-rw-r--r--benches/Jellyfin.Common.Benches/HexEncodeBenches.cs32
-rw-r--r--benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj16
-rw-r--r--benches/Jellyfin.Common.Benches/Program.cs14
-rwxr-xr-xbump_version1
-rwxr-xr-xdebian/bin/restart.sh6
-rw-r--r--fedora/jellyfin.spec2
-rwxr-xr-xfedora/restart.sh6
-rw-r--r--tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs4
-rw-r--r--tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj2
-rw-r--r--tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs3
-rw-r--r--tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs226
-rw-r--r--tests/Jellyfin.Common.Tests/HexTests.cs19
-rw-r--r--tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs42
-rw-r--r--tests/Jellyfin.Common.Tests/PasswordHashTests.cs5
-rw-r--r--tests/Jellyfin.Dlna.Tests/GetUuidTests.cs17
-rw-r--r--tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj33
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs1
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs8
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj39
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkTesting/NetworkParseTests.cs519
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj2
405 files changed, 16879 insertions, 7075 deletions
diff --git a/.ci/azure-pipelines-api-client.yml b/.ci/azure-pipelines-api-client.yml
index 1c447fd97..177f78889 100644
--- a/.ci/azure-pipelines-api-client.yml
+++ b/.ci/azure-pipelines-api-client.yml
@@ -52,7 +52,8 @@ jobs:
- task: Npm@1
displayName: 'Publish stable typescript axios client'
inputs:
- command: publish
+ command: custom
+ customCommand: publish --access public
publishRegistry: useExternalRegistry
publishEndpoint: 'jellyfin-bot for NPM'
workingDir: ./apiclient/generated/typescript/axios
diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml
index d478516b8..47477ba60 100644
--- a/.ci/azure-pipelines-package.yml
+++ b/.ci/azure-pipelines-package.yml
@@ -221,12 +221,6 @@ jobs:
pathToPublish: $(Build.ArtifactStagingDirectory)
artifactName: Jellyfin Nuget Packages
- - task: NuGetAuthenticate@0
- displayName: 'Authenticate to stable Nuget feed'
- condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
- inputs:
- nuGetServiceConnections: 'NugetOrg'
-
- task: NuGetCommand@2
displayName: 'Push Nuget packages to stable feed'
condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
diff --git a/.ci/azure-pipelines-test.yml b/.ci/azure-pipelines-test.yml
index 4ceda978a..36152c82a 100644
--- a/.ci/azure-pipelines-test.yml
+++ b/.ci/azure-pipelines-test.yml
@@ -30,11 +30,11 @@ jobs:
# This is required for the SonarCloud analyzer
- task: UseDotNet@2
- displayName: "Install .NET Core SDK 2.1"
+ displayName: "Install .NET SDK 5.x"
condition: eq(variables['ImageName'], 'ubuntu-latest')
inputs:
packageType: sdk
- version: '2.1.805'
+ version: '5.x'
- task: UseDotNet@2
displayName: "Update DotNet"
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index e902dc712..e1900d583 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,3 +1,4 @@
# Joshua must review all changes to deployment and build.sh
+.ci/* @joshuaboniface
deployment/* @joshuaboniface
build.sh @joshuaboniface
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 000000000..538894818
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,36 @@
+name: "CodeQL"
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+ schedule:
+ - cron: '24 2 * * 4'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ language: [ 'csharp' ]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ - name: Setup .NET Core
+ uses: actions/setup-dotnet@v1
+ with:
+ dotnet-version: '5.0.100'
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ with:
+ languages: ${{ matrix.language }}
+ queries: +security-extended
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v1
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index a97a4c741..a63db6ed7 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -7,6 +7,7 @@
- [anthonylavado](https://github.com/anthonylavado)
- [Artiume](https://github.com/Artiume)
- [AThomsen](https://github.com/AThomsen)
+ - [barongreenback](https://github.com/BaronGreenback)
- [barronpm](https://github.com/barronpm)
- [bilde2910](https://github.com/bilde2910)
- [bfayers](https://github.com/bfayers)
@@ -78,6 +79,7 @@
- [Nickbert7](https://github.com/Nickbert7)
- [nvllsvm](https://github.com/nvllsvm)
- [nyanmisaka](https://github.com/nyanmisaka)
+ - [OancaAndrei](https://github.com/OancaAndrei)
- [oddstr13](https://github.com/oddstr13)
- [orryverducci](https://github.com/orryverducci)
- [petermcneil](https://github.com/petermcneil)
diff --git a/Dockerfile b/Dockerfile
index 963027b49..41dd3d081 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,8 +27,15 @@ ENV NVIDIA_DRIVER_CAPABILITIES="compute,video,utility"
COPY --from=builder /jellyfin /jellyfin
COPY --from=web-builder /dist /jellyfin/jellyfin-web
+
+# https://github.com/intel/compute-runtime/releases
+ARG GMMLIB_VERSION=20.3.2
+ARG IGC_VERSION=1.0.5435
+ARG NEO_VERSION=20.46.18421
+ARG LEVEL_ZERO_VERSION=1.0.18421
+
# Install dependencies:
-# mesa-va-drivers: needed for AMD VAAPI
+# mesa-va-drivers: needed for AMD VAAPI. Mesa >= 20.1 is required for HEVC transcoding.
RUN apt-get update \
&& apt-get install --no-install-recommends --no-install-suggests -y ca-certificates gnupg wget apt-transport-https \
&& wget -O - https://repo.jellyfin.org/jellyfin_team.gpg.key | apt-key add - \
@@ -39,6 +46,20 @@ RUN apt-get update \
jellyfin-ffmpeg \
openssl \
locales \
+# Intel VAAPI Tone mapping dependencies:
+# Prefer NEO to Beignet since the latter one doesn't support Comet Lake or newer for now.
+# Do not use the intel-opencl-icd package from repo since they will not build with RELEASE_WITH_REGKEYS enabled.
+ && mkdir intel-compute-runtime \
+ && cd intel-compute-runtime \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-gmmlib_${GMMLIB_VERSION}_amd64.deb \
+ && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-core_${IGC_VERSION}_amd64.deb \
+ && wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-${IGC_VERSION}/intel-igc-opencl_${IGC_VERSION}_amd64.deb \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-opencl_${NEO_VERSION}_amd64.deb \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-ocloc_${NEO_VERSION}_amd64.deb \
+ && wget https://github.com/intel/compute-runtime/releases/download/${NEO_VERSION}/intel-level-zero-gpu_${LEVEL_ZERO_VERSION}_amd64.deb \
+ && dpkg -i *.deb \
+ && cd .. \
+ && rm -rf intel-compute-runtime \
&& apt-get remove gnupg wget apt-transport-https -y \
&& apt-get clean autoclean -y \
&& apt-get autoremove -y \
diff --git a/Emby.Dlna/Common/Argument.cs b/Emby.Dlna/Common/Argument.cs
index 430a3b47d..e4e9c55e0 100644
--- a/Emby.Dlna/Common/Argument.cs
+++ b/Emby.Dlna/Common/Argument.cs
@@ -1,7 +1,7 @@
namespace Emby.Dlna.Common
{
/// <summary>
- /// DLNA Query parameter type, used when quering DLNA devices via SOAP.
+ /// DLNA Query parameter type, used when querying DLNA devices via SOAP.
/// </summary>
public class Argument
{
diff --git a/Emby.Dlna/Configuration/DlnaOptions.cs b/Emby.Dlna/Configuration/DlnaOptions.cs
index 6dd9a445a..e63a85860 100644
--- a/Emby.Dlna/Configuration/DlnaOptions.cs
+++ b/Emby.Dlna/Configuration/DlnaOptions.cs
@@ -2,8 +2,14 @@
namespace Emby.Dlna.Configuration
{
+ /// <summary>
+ /// The DlnaOptions class contains the user definable parameters for the dlna subsystems.
+ /// </summary>
public class DlnaOptions
{
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DlnaOptions"/> class.
+ /// </summary>
public DlnaOptions()
{
EnablePlayTo = true;
@@ -11,23 +17,76 @@ namespace Emby.Dlna.Configuration
BlastAliveMessages = true;
SendOnlyMatchedHost = true;
ClientDiscoveryIntervalSeconds = 60;
- BlastAliveMessageIntervalSeconds = 1800;
+ AliveMessageIntervalSeconds = 1800;
}
+ /// <summary>
+ /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna playTo subsystem.
+ /// </summary>
public bool EnablePlayTo { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether gets or sets a value to indicate the status of the dlna server subsystem.
+ /// </summary>
public bool EnableServer { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether detailed dlna server logs are sent to the console/log.
+ /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work.
+ /// </summary>
public bool EnableDebugLog { get; set; }
- public bool BlastAliveMessages { get; set; }
-
- public bool SendOnlyMatchedHost { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether whether detailed playTo debug logs are sent to the console/log.
+ /// If the setting "Emby.Dlna.PlayTo": "Debug" msut be set in logging.default.json for this property to work.
+ /// </summary>
+ public bool EnablePlayToTracing { get; set; }
+ /// <summary>
+ /// Gets or sets the ssdp client discovery interval time (in seconds).
+ /// This is the time after which the server will send a ssdp search request.
+ /// </summary>
public int ClientDiscoveryIntervalSeconds { get; set; }
- public int BlastAliveMessageIntervalSeconds { get; set; }
+ /// <summary>
+ /// Gets or sets the frequency at which ssdp alive notifications are transmitted.
+ /// </summary>
+ public int AliveMessageIntervalSeconds { get; set; }
+
+ /// <summary>
+ /// Gets or sets the frequency at which ssdp alive notifications are transmitted. MIGRATING - TO BE REMOVED ONCE WEB HAS BEEN ALTERED.
+ /// </summary>
+ public int BlastAliveMessageIntervalSeconds
+ {
+ get
+ {
+ return AliveMessageIntervalSeconds;
+ }
+
+ set
+ {
+ AliveMessageIntervalSeconds = value;
+ }
+ }
+ /// <summary>
+ /// Gets or sets the default user account that the dlna server uses.
+ /// </summary>
public string DefaultUserId { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether playTo device profiles should be created.
+ /// </summary>
+ public bool AutoCreatePlayToProfiles { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to blast alive messages.
+ /// </summary>
+ public bool BlastAliveMessages { get; set; } = true;
+
+ /// <summary>
+ /// gets or sets a value indicating whether to send only matched host.
+ /// </summary>
+ public bool SendOnlyMatchedHost { get; set; } = true;
}
}
diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs
index f5a7eca72..916044a0c 100644
--- a/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs
+++ b/Emby.Dlna/ConnectionManager/ConnectionManagerService.cs
@@ -9,11 +9,21 @@ using Microsoft.Extensions.Logging;
namespace Emby.Dlna.ConnectionManager
{
+ /// <summary>
+ /// Defines the <see cref="ConnectionManagerService" />.
+ /// </summary>
public class ConnectionManagerService : BaseService, IConnectionManager
{
private readonly IDlnaManager _dlna;
private readonly IServerConfigurationManager _config;
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ConnectionManagerService"/> class.
+ /// </summary>
+ /// <param name="dlna">The <see cref="IDlnaManager"/> for use with the <see cref="ConnectionManagerService"/> instance.</param>
+ /// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="ConnectionManagerService"/> instance.</param>
+ /// <param name="logger">The <see cref="ILogger{ConnectionManagerService}"/> for use with the <see cref="ConnectionManagerService"/> instance..</param>
+ /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/> for use with the <see cref="ConnectionManagerService"/> instance..</param>
public ConnectionManagerService(
IDlnaManager dlna,
IServerConfigurationManager config,
@@ -28,7 +38,7 @@ namespace Emby.Dlna.ConnectionManager
/// <inheritdoc />
public string GetServiceXml()
{
- return new ConnectionManagerXmlBuilder().GetXml();
+ return ConnectionManagerXmlBuilder.GetXml();
}
/// <inheritdoc />
diff --git a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs
index c8db5a367..c484dac54 100644
--- a/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs
+++ b/Emby.Dlna/ConnectionManager/ConnectionManagerXmlBuilder.cs
@@ -6,45 +6,57 @@ using Emby.Dlna.Service;
namespace Emby.Dlna.ConnectionManager
{
- public class ConnectionManagerXmlBuilder
+ /// <summary>
+ /// Defines the <see cref="ConnectionManagerXmlBuilder" />.
+ /// </summary>
+ public static class ConnectionManagerXmlBuilder
{
- public string GetXml()
+ /// <summary>
+ /// Gets the ConnectionManager:1 service template.
+ /// See http://upnp.org/specs/av/UPnP-av-ConnectionManager-v1-Service.pdf.
+ /// </summary>
+ /// <returns>An XML description of this service.</returns>
+ public static string GetXml()
{
- return new ServiceXmlBuilder().GetXml(new ServiceActionListBuilder().GetActions(), GetStateVariables());
+ return new ServiceXmlBuilder().GetXml(ServiceActionListBuilder.GetActions(), GetStateVariables());
}
+ /// <summary>
+ /// Get the list of state variables for this invocation.
+ /// </summary>
+ /// <returns>The <see cref="IEnumerable{StateVariable}"/>.</returns>
private static IEnumerable<StateVariable> GetStateVariables()
{
- var list = new List<StateVariable>();
-
- list.Add(new StateVariable
+ var list = new List<StateVariable>
{
- Name = "SourceProtocolInfo",
- DataType = "string",
- SendsEvents = true
- });
+ new StateVariable
+ {
+ Name = "SourceProtocolInfo",
+ DataType = "string",
+ SendsEvents = true
+ },
- list.Add(new StateVariable
- {
- Name = "SinkProtocolInfo",
- DataType = "string",
- SendsEvents = true
- });
+ new StateVariable
+ {
+ Name = "SinkProtocolInfo",
+ DataType = "string",
+ SendsEvents = true
+ },
- list.Add(new StateVariable
- {
- Name = "CurrentConnectionIDs",
- DataType = "string",
- SendsEvents = true
- });
+ new StateVariable
+ {
+ Name = "CurrentConnectionIDs",
+ DataType = "string",
+ SendsEvents = true
+ },
- list.Add(new StateVariable
- {
- Name = "A_ARG_TYPE_ConnectionStatus",
- DataType = "string",
- SendsEvents = false,
+ new StateVariable
+ {
+ Name = "A_ARG_TYPE_ConnectionStatus",
+ DataType = "string",
+ SendsEvents = false,
- AllowedValues = new[]
+ AllowedValues = new[]
{
"OK",
"ContentFormatMismatch",
@@ -52,55 +64,56 @@ namespace Emby.Dlna.ConnectionManager
"UnreliableChannel",
"Unknown"
}
- });
+ },
- list.Add(new StateVariable
- {
- Name = "A_ARG_TYPE_ConnectionManager",
- DataType = "string",
- SendsEvents = false
- });
+ new StateVariable
+ {
+ Name = "A_ARG_TYPE_ConnectionManager",
+ DataType = "string",
+ SendsEvents = false
+ },
- list.Add(new StateVariable
- {
- Name = "A_ARG_TYPE_Direction",
- DataType = "string",
- SendsEvents = false,
+ new StateVariable
+ {
+ Name = "A_ARG_TYPE_Direction",
+ DataType = "string",
+ SendsEvents = false,
- AllowedValues = new[]
+ AllowedValues = new[]
{
"Output",
"Input"
}
- });
+ },
- list.Add(new StateVariable
- {
- Name = "A_ARG_TYPE_ProtocolInfo",
- DataType = "string",
- SendsEvents = false
- });
+ new StateVariable
+ {
+ Name = "A_ARG_TYPE_ProtocolInfo",
+ DataType = "string",
+ SendsEvents = false
+ },
- list.Add(new StateVariable
- {
- Name = "A_ARG_TYPE_ConnectionID",
- DataType = "ui4",
- SendsEvents = false
- });
+ new StateVariable
+ {
+ Name = "A_ARG_TYPE_ConnectionID",
+ DataType = "ui4",
+ SendsEvents = false
+ },
- list.Add(new StateVariable
- {
- Name = "A_ARG_TYPE_AVTransportID",
- DataType = "ui4",
- SendsEvents = false
- });
+ new StateVariable
+ {
+ Name = "A_ARG_TYPE_AVTransportID",
+ DataType = "ui4",
+ SendsEvents = false
+ },
- list.Add(new StateVariable
- {
- Name = "A_ARG_TYPE_RcsID",
- DataType = "ui4",
- SendsEvents = false
- });
+ new StateVariable
+ {
+ Name = "A_ARG_TYPE_RcsID",
+ DataType = "ui4",
+ SendsEvents = false
+ }
+ };
return list;
}
diff --git a/Emby.Dlna/ConnectionManager/ControlHandler.cs b/Emby.Dlna/ConnectionManager/ControlHandler.cs
index d4cc65394..2f8d197a7 100644
--- a/Emby.Dlna/ConnectionManager/ControlHandler.cs
+++ b/Emby.Dlna/ConnectionManager/ControlHandler.cs
@@ -11,10 +11,19 @@ using Microsoft.Extensions.Logging;
namespace Emby.Dlna.ConnectionManager
{
+ /// <summary>
+ /// Defines the <see cref="ControlHandler" />.
+ /// </summary>
public class ControlHandler : BaseControlHandler
{
private readonly DeviceProfile _profile;
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ControlHandler"/> class.
+ /// </summary>
+ /// <param name="config">The <see cref="IServerConfigurationManager"/> for use with the <see cref="ControlHandler"/> instance.</param>
+ /// <param name="logger">The <see cref="ILogger"/> for use with the <see cref="ControlHandler"/> instance.</param>
+ /// <param name="profile">The <see cref="DeviceProfile"/> for use with the <see cref="ControlHandler"/> instance.</param>
public ControlHandler(IServerConfigurationManager config, ILogger logger, DeviceProfile profile)
: base(config, logger)
{
@@ -33,6 +42,10 @@ namespace Emby.Dlna.ConnectionManager
throw new ResourceNotFoundException("Unexpected control request name: " + methodName);
}
+ /// <summary>
+ /// Builds the response to the GetProtocolInfo request.
+ /// </summary>
+ /// <param name="xmlWriter">The <see cref="XmlWriter"/>.</param>
private void HandleGetProtocolInfo(XmlWriter xmlWriter)
{
xmlWriter.WriteElementString("Source", _profile.ProtocolInfo);
diff --git a/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs b/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs
index b853e7eab..542c7bfb4 100644
--- a/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs
+++ b/Emby.Dlna/ConnectionManager/ServiceActionListBuilder.cs
@@ -5,9 +5,16 @@ using Emby.Dlna.Common;
namespace Emby.Dlna.ConnectionManager
{
- public class ServiceActionListBuilder
+ /// <summary>
+ /// Defines the <see cref="ServiceActionListBuilder" />.
+ /// </summary>
+ public static class ServiceActionListBuilder
{
- public IEnumerable<ServiceAction> GetActions()
+ /// <summary>
+ /// Returns an enumerable of the ConnectionManagar:1 DLNA actions.
+ /// </summary>
+ /// <returns>An <see cref="IEnumerable{ServiceAction}"/>.</returns>
+ public static IEnumerable<ServiceAction> GetActions()
{
var list = new List<ServiceAction>
{
@@ -21,6 +28,10 @@ namespace Emby.Dlna.ConnectionManager
return list;
}
+ /// <summary>
+ /// Returns the action details for "PrepareForConnection".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
private static ServiceAction PrepareForConnection()
{
var action = new ServiceAction
@@ -80,6 +91,10 @@ namespace Emby.Dlna.ConnectionManager
return action;
}
+ /// <summary>
+ /// Returns the action details for "GetCurrentConnectionInfo".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
private static ServiceAction GetCurrentConnectionInfo()
{
var action = new ServiceAction
@@ -146,7 +161,11 @@ namespace Emby.Dlna.ConnectionManager
return action;
}
- private ServiceAction GetProtocolInfo()
+ /// <summary>
+ /// Returns the action details for "GetProtocolInfo".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
+ private static ServiceAction GetProtocolInfo()
{
var action = new ServiceAction
{
@@ -170,7 +189,11 @@ namespace Emby.Dlna.ConnectionManager
return action;
}
- private ServiceAction GetCurrentConnectionIDs()
+ /// <summary>
+ /// Returns the action details for "GetCurrentConnectionIDs".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
+ private static ServiceAction GetCurrentConnectionIDs()
{
var action = new ServiceAction
{
@@ -187,7 +210,11 @@ namespace Emby.Dlna.ConnectionManager
return action;
}
- private ServiceAction ConnectionComplete()
+ /// <summary>
+ /// Returns the action details for "ConnectionComplete".
+ /// </summary>
+ /// <returns>The <see cref="ServiceAction"/>.</returns>
+ private static ServiceAction ConnectionComplete()
{
var action = new ServiceAction
{
diff --git a/Emby.Dlna/ContentDirectory/ControlHandler.cs b/Emby.Dlna/ContentDirectory/ControlHandler.cs
index 9f35c1959..27f1fdaba 100644
--- a/Emby.Dlna/ContentDirectory/ControlHandler.cs
+++ b/Emby.Dlna/ContentDirectory/ControlHandler.cs
@@ -1674,14 +1674,13 @@ namespace Emby.Dlna.ContentDirectory
}
/// <summary>
- /// Retreives the ServerItem id.
+ /// Retrieves the ServerItem id.
/// </summary>
/// <param name="id">The id<see cref="string"/>.</param>
/// <returns>The <see cref="ServerItem"/>.</returns>
private ServerItem GetItemFromObjectId(string id)
{
return DidlBuilder.IsIdRoot(id)
-
? new ServerItem(_libraryManager.GetUserRootFolder())
: ParseItemId(id);
}
diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs
index 069400833..fedd20b68 100644
--- a/Emby.Dlna/DlnaManager.cs
+++ b/Emby.Dlna/DlnaManager.cs
@@ -484,10 +484,10 @@ namespace Emby.Dlna
/// <summary>
/// Recreates the object using serialization, to ensure it's not a subclass.
- /// If it's a subclass it may not serlialize properly to xml (different root element tag name).
+ /// If it's a subclass it may not serialize properly to xml (different root element tag name).
/// </summary>
/// <param name="profile">The device profile.</param>
- /// <returns>The reserialized device profile.</returns>
+ /// <returns>The re-serialized device profile.</returns>
private DeviceProfile ReserializeProfile(DeviceProfile profile)
{
if (profile.GetType() == typeof(DeviceProfile))
diff --git a/Emby.Dlna/Eventing/DlnaEventManager.cs b/Emby.Dlna/Eventing/DlnaEventManager.cs
index b6e45c50e..ff81e83b5 100644
--- a/Emby.Dlna/Eventing/DlnaEventManager.cs
+++ b/Emby.Dlna/Eventing/DlnaEventManager.cs
@@ -72,7 +72,8 @@ namespace Emby.Dlna.Eventing
Id = id,
CallbackUrl = callbackUrl,
SubscriptionTime = DateTime.UtcNow,
- TimeoutSeconds = timeout
+ TimeoutSeconds = timeout,
+ NotificationType = notificationType
});
return GetEventSubscriptionResponse(id, requestedTimeoutString, timeout);
diff --git a/Emby.Dlna/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs
index f8a00efac..fb4454a34 100644
--- a/Emby.Dlna/Main/DlnaEntryPoint.cs
+++ b/Emby.Dlna/Main/DlnaEntryPoint.cs
@@ -2,12 +2,14 @@
using System;
using System.Globalization;
+using System.Linq;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Emby.Dlna.PlayTo;
using Emby.Dlna.Ssdp;
+using Jellyfin.Networking.Manager;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
@@ -134,20 +136,20 @@ namespace Emby.Dlna.Main
{
await ((DlnaManager)_dlnaManager).InitProfilesAsync().ConfigureAwait(false);
- await ReloadComponents().ConfigureAwait(false);
+ ReloadComponents();
_config.NamedConfigurationUpdated += OnNamedConfigurationUpdated;
}
- private async void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
+ private void OnNamedConfigurationUpdated(object sender, ConfigurationUpdateEventArgs e)
{
if (string.Equals(e.Key, "dlna", StringComparison.OrdinalIgnoreCase))
{
- await ReloadComponents().ConfigureAwait(false);
+ ReloadComponents();
}
}
- private async Task ReloadComponents()
+ private void ReloadComponents()
{
var options = _config.GetDlnaConfiguration();
@@ -155,7 +157,7 @@ namespace Emby.Dlna.Main
if (options.EnableServer)
{
- await StartDevicePublisher(options).ConfigureAwait(false);
+ StartDevicePublisher(options);
}
else
{
@@ -225,7 +227,7 @@ namespace Emby.Dlna.Main
}
}
- public async Task StartDevicePublisher(Configuration.DlnaOptions options)
+ public void StartDevicePublisher(Configuration.DlnaOptions options)
{
if (!options.BlastAliveMessages)
{
@@ -245,7 +247,7 @@ namespace Emby.Dlna.Main
SupportPnpRootDevice = false
};
- await RegisterServerEndpoints().ConfigureAwait(false);
+ RegisterServerEndpoints();
_publisher.StartBroadcastingAliveMessages(TimeSpan.FromSeconds(options.BlastAliveMessageIntervalSeconds));
}
@@ -255,14 +257,22 @@ namespace Emby.Dlna.Main
}
}
- private async Task RegisterServerEndpoints()
+ private void RegisterServerEndpoints()
{
- var addresses = await _appHost.GetLocalIpAddresses().ConfigureAwait(false);
-
var udn = CreateUuid(_appHost.SystemId);
var descriptorUri = "/dlna/" + udn + "/description.xml";
- foreach (var address in addresses)
+ var bindAddresses = NetworkManager.CreateCollection(
+ _networkManager.GetInternalBindAddresses()
+ .Where(i => i.AddressFamily == AddressFamily.InterNetwork || (i.AddressFamily == AddressFamily.InterNetworkV6 && i.Address.ScopeId != 0)));
+
+ if (bindAddresses.Count == 0)
+ {
+ // No interfaces returned, so use loopback.
+ bindAddresses = _networkManager.GetLoopbacks();
+ }
+
+ foreach (IPNetAddress address in bindAddresses)
{
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
@@ -271,7 +281,7 @@ namespace Emby.Dlna.Main
}
// Limit to LAN addresses only
- if (!_networkManager.IsAddressInSubnets(address, true, true))
+ if (!_networkManager.IsInLocalNetwork(address))
{
continue;
}
@@ -280,14 +290,14 @@ namespace Emby.Dlna.Main
_logger.LogInformation("Registering publisher for {0} on {1}", fullService, address);
- var uri = new Uri(_appHost.GetLocalApiUrl(address) + descriptorUri);
+ var uri = new Uri(_appHost.GetSmartApiUrl(address.Address) + descriptorUri);
var device = new SsdpRootDevice
{
CacheLifetime = TimeSpan.FromSeconds(1800), // How long SSDP clients can cache this info.
Location = uri, // Must point to the URL that serves your devices UPnP description document.
- Address = address,
- SubnetMask = _networkManager.GetLocalIpSubnetMask(address),
+ Address = address.Address,
+ PrefixLength = address.PrefixLength,
FriendlyName = "Jellyfin",
Manufacturer = "Jellyfin",
ModelName = "Jellyfin Server",
diff --git a/Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs b/Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs
index 1dc9c79c1..56788ae22 100644
--- a/Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs
+++ b/Emby.Dlna/MediaReceiverRegistrar/ServiceActionListBuilder.cs
@@ -1,6 +1,5 @@
using System.Collections.Generic;
using Emby.Dlna.Common;
-using MediaBrowser.Model.Dlna;
namespace Emby.Dlna.MediaReceiverRegistrar
{
diff --git a/Emby.Dlna/PlayTo/Device.cs b/Emby.Dlna/PlayTo/Device.cs
index c97acdb02..938ce5fbf 100644
--- a/Emby.Dlna/PlayTo/Device.cs
+++ b/Emby.Dlna/PlayTo/Device.cs
@@ -12,8 +12,6 @@ using System.Xml;
using System.Xml.Linq;
using Emby.Dlna.Common;
using Emby.Dlna.Ssdp;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Configuration;
using Microsoft.Extensions.Logging;
namespace Emby.Dlna.PlayTo
@@ -345,7 +343,7 @@ namespace Emby.Dlna.PlayTo
RestartTimer(true);
}
- private string CreateDidlMeta(string value)
+ private static string CreateDidlMeta(string value)
{
if (string.IsNullOrEmpty(value))
{
@@ -480,7 +478,7 @@ namespace Emby.Dlna.PlayTo
return;
}
- // If we're not playing anything make sure we don't get data more often than neccessry to keep the Session alive
+ // If we're not playing anything make sure we don't get data more often than necessary to keep the Session alive
if (transportState.Value == TransportState.Stopped)
{
RestartTimerInactive();
@@ -775,7 +773,7 @@ namespace Emby.Dlna.PlayTo
if (track == null)
{
- // If track is null, some vendors do this, use GetMediaInfo instead
+ // If track is null, some vendors do this, use GetMediaInfo instead.
return (true, null);
}
@@ -812,7 +810,7 @@ namespace Emby.Dlna.PlayTo
private XElement ParseResponse(string xml)
{
- // Handle different variations sent back by devices
+ // Handle different variations sent back by devices.
try
{
return XElement.Parse(xml);
@@ -821,7 +819,7 @@ namespace Emby.Dlna.PlayTo
{
}
- // first try to add a root node with a dlna namesapce
+ // first try to add a root node with a dlna namespace.
try
{
return XElement.Parse("<data xmlns:dlna=\"urn:schemas-dlna-org:device-1-0\">" + xml + "</data>")
@@ -962,7 +960,7 @@ namespace Emby.Dlna.PlayTo
url = "/dmr/" + url;
}
- if (!url.StartsWith("/", StringComparison.Ordinal))
+ if (!url.StartsWith('/'))
{
url = "/" + url;
}
diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs
index 3907b2a39..b7cd91a5c 100644
--- a/Emby.Dlna/PlayTo/PlayToController.cs
+++ b/Emby.Dlna/PlayTo/PlayToController.cs
@@ -9,7 +9,6 @@ using System.Threading.Tasks;
using Emby.Dlna.Didl;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Events;
-using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Dlna;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Entities;
@@ -41,7 +40,6 @@ namespace Emby.Dlna.PlayTo
private readonly IUserDataManager _userDataManager;
private readonly ILocalizationManager _localization;
private readonly IMediaSourceManager _mediaSourceManager;
- private readonly IConfigurationManager _config;
private readonly IMediaEncoder _mediaEncoder;
private readonly IDeviceDiscovery _deviceDiscovery;
@@ -68,7 +66,6 @@ namespace Emby.Dlna.PlayTo
IUserDataManager userDataManager,
ILocalizationManager localization,
IMediaSourceManager mediaSourceManager,
- IConfigurationManager config,
IMediaEncoder mediaEncoder)
{
_session = session;
@@ -84,7 +81,6 @@ namespace Emby.Dlna.PlayTo
_userDataManager = userDataManager;
_localization = localization;
_mediaSourceManager = mediaSourceManager;
- _config = config;
_mediaEncoder = mediaEncoder;
}
@@ -337,25 +333,17 @@ namespace Emby.Dlna.PlayTo
}
var startIndex = command.StartIndex ?? 0;
+ int len = items.Count - startIndex;
if (startIndex > 0)
{
- items = items.GetRange(startIndex, items.Count - startIndex);
+ items = items.GetRange(startIndex, len);
}
- var playlist = new List<PlaylistItem>();
- var isFirst = true;
-
- foreach (var item in items)
+ var playlist = new PlaylistItem[len];
+ playlist[0] = CreatePlaylistItem(items[0], user, command.StartPositionTicks.Value, command.MediaSourceId, command.AudioStreamIndex, command.SubtitleStreamIndex);
+ for (int i = 1; i < len; i++)
{
- if (isFirst && command.StartPositionTicks.HasValue)
- {
- playlist.Add(CreatePlaylistItem(item, user, command.StartPositionTicks.Value, command.MediaSourceId, command.AudioStreamIndex, command.SubtitleStreamIndex));
- isFirst = false;
- }
- else
- {
- playlist.Add(CreatePlaylistItem(item, user, 0, null, null, null));
- }
+ playlist[i] = CreatePlaylistItem(items[i], user, 0, null, null, null);
}
_logger.LogDebug("{0} - Playlist created", _session.DeviceName);
@@ -468,8 +456,8 @@ namespace Emby.Dlna.PlayTo
_dlnaManager.GetDefaultProfile();
var mediaSources = item is IHasMediaSources
- ? _mediaSourceManager.GetStaticMediaSources(item, true, user)
- : new List<MediaSourceInfo>();
+ ? _mediaSourceManager.GetStaticMediaSources(item, true, user).ToArray()
+ : Array.Empty<MediaSourceInfo>();
var playlistItem = GetPlaylistItem(item, mediaSources, profile, _session.DeviceId, mediaSourceId, audioStreamIndex, subtitleStreamIndex);
playlistItem.StreamInfo.StartPositionTicks = startPostionTicks;
@@ -548,7 +536,7 @@ namespace Emby.Dlna.PlayTo
return null;
}
- private PlaylistItem GetPlaylistItem(BaseItem item, List<MediaSourceInfo> mediaSources, DeviceProfile profile, string deviceId, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
+ private PlaylistItem GetPlaylistItem(BaseItem item, MediaSourceInfo[] mediaSources, DeviceProfile profile, string deviceId, string mediaSourceId, int? audioStreamIndex, int? subtitleStreamIndex)
{
if (string.Equals(item.MediaType, MediaType.Video, StringComparison.OrdinalIgnoreCase))
{
@@ -557,7 +545,7 @@ namespace Emby.Dlna.PlayTo
StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildVideoItem(new VideoOptions
{
ItemId = item.Id,
- MediaSources = mediaSources.ToArray(),
+ MediaSources = mediaSources,
Profile = profile,
DeviceId = deviceId,
MaxBitrate = profile.MaxStreamingBitrate,
@@ -577,7 +565,7 @@ namespace Emby.Dlna.PlayTo
StreamInfo = new StreamBuilder(_mediaEncoder, _logger).BuildAudioItem(new AudioOptions
{
ItemId = item.Id,
- MediaSources = mediaSources.ToArray(),
+ MediaSources = mediaSources,
Profile = profile,
DeviceId = deviceId,
MaxBitrate = profile.MaxStreamingBitrate,
@@ -590,7 +578,7 @@ namespace Emby.Dlna.PlayTo
if (string.Equals(item.MediaType, MediaType.Photo, StringComparison.OrdinalIgnoreCase))
{
- return new PlaylistItemFactory().Create((Photo)item, profile);
+ return PlaylistItemFactory.Create((Photo)item, profile);
}
throw new ArgumentException("Unrecognized item type.");
@@ -774,13 +762,14 @@ namespace Emby.Dlna.PlayTo
private async Task SeekAfterTransportChange(long positionTicks, CancellationToken cancellationToken)
{
- const int maxWait = 15000000;
- const int interval = 500;
+ const int MaxWait = 15000000;
+ const int Interval = 500;
+
var currentWait = 0;
- while (_device.TransportState != TransportState.Playing && currentWait < maxWait)
+ while (_device.TransportState != TransportState.Playing && currentWait < MaxWait)
{
- await Task.Delay(interval).ConfigureAwait(false);
- currentWait += interval;
+ await Task.Delay(Interval).ConfigureAwait(false);
+ currentWait += Interval;
}
await _device.Seek(TimeSpan.FromTicks(positionTicks), cancellationToken).ConfigureAwait(false);
diff --git a/Emby.Dlna/PlayTo/PlayToManager.cs b/Emby.Dlna/PlayTo/PlayToManager.cs
index e93aef304..a6793a708 100644
--- a/Emby.Dlna/PlayTo/PlayToManager.cs
+++ b/Emby.Dlna/PlayTo/PlayToManager.cs
@@ -3,13 +3,11 @@
using System;
using System.Globalization;
using System.Linq;
-using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using MediaBrowser.Common.Extensions;
-using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dlna;
@@ -89,13 +87,10 @@ namespace Emby.Dlna.PlayTo
nt = string.Empty;
}
- string location = info.Location.ToString();
-
// It has to report that it's a media renderer
- if (usn.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) == -1 &&
- nt.IndexOf("MediaRenderer:", StringComparison.OrdinalIgnoreCase) == -1)
+ if (!usn.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase)
+ && !nt.Contains("MediaRenderer:", StringComparison.OrdinalIgnoreCase))
{
- // _logger.LogDebug("Upnp device {0} does not contain a MediaRenderer device (0).", location);
return;
}
@@ -115,7 +110,7 @@ namespace Emby.Dlna.PlayTo
return;
}
- await AddDevice(info, location, cancellationToken).ConfigureAwait(false);
+ await AddDevice(info, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -130,39 +125,50 @@ namespace Emby.Dlna.PlayTo
}
}
- private static string GetUuid(string usn)
+ internal static string GetUuid(string usn)
{
const string UuidStr = "uuid:";
const string UuidColonStr = "::";
var index = usn.IndexOf(UuidStr, StringComparison.OrdinalIgnoreCase);
+ if (index == -1)
+ {
+ return usn.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ }
+
+ ReadOnlySpan<char> tmp = usn.AsSpan()[(index + UuidStr.Length)..];
+
+ index = tmp.IndexOf(UuidColonStr, StringComparison.OrdinalIgnoreCase);
if (index != -1)
{
- return usn.Substring(index + UuidStr.Length);
+ tmp = tmp[..index];
}
- index = usn.IndexOf(UuidColonStr, StringComparison.OrdinalIgnoreCase);
+ index = tmp.IndexOf('{');
if (index != -1)
{
- usn = usn.Substring(0, index + UuidColonStr.Length);
+ int endIndex = tmp.IndexOf('}');
+ if (endIndex != -1)
+ {
+ tmp = tmp[(index + 1)..endIndex];
+ }
}
- return usn.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ return tmp.ToString();
}
- private async Task AddDevice(UpnpDeviceInfo info, string location, CancellationToken cancellationToken)
+ private async Task AddDevice(UpnpDeviceInfo info, CancellationToken cancellationToken)
{
var uri = info.Location;
- _logger.LogDebug("Attempting to create PlayToController from location {0}", location);
+ _logger.LogDebug("Attempting to create PlayToController from location {0}", uri);
- _logger.LogDebug("Logging session activity from location {0}", location);
if (info.Headers.TryGetValue("USN", out string uuid))
{
uuid = GetUuid(uuid);
}
else
{
- uuid = location.GetMD5().ToString("N", CultureInfo.InvariantCulture);
+ uuid = uri.ToString().GetMD5().ToString("N", CultureInfo.InvariantCulture);
}
var sessionInfo = _sessionManager.LogSessionActivity("DLNA", _appHost.ApplicationVersionString, uuid, null, uri.OriginalString, null);
@@ -177,15 +183,7 @@ namespace Emby.Dlna.PlayTo
_sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName);
- string serverAddress;
- if (info.LocalIpAddress == null || info.LocalIpAddress.Equals(IPAddress.Any) || info.LocalIpAddress.Equals(IPAddress.IPv6Any))
- {
- serverAddress = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
- }
- else
- {
- serverAddress = _appHost.GetLocalApiUrl(info.LocalIpAddress);
- }
+ string serverAddress = _appHost.GetSmartApiUrl(info.LocalIpAddress);
controller = new PlayToController(
sessionInfo,
@@ -201,7 +199,6 @@ namespace Emby.Dlna.PlayTo
_userDataManager,
_localization,
_mediaSourceManager,
- _config,
_mediaEncoder);
sessionInfo.AddController(controller);
diff --git a/Emby.Dlna/PlayTo/PlaylistItemFactory.cs b/Emby.Dlna/PlayTo/PlaylistItemFactory.cs
index bedc8b9ad..e28840a89 100644
--- a/Emby.Dlna/PlayTo/PlaylistItemFactory.cs
+++ b/Emby.Dlna/PlayTo/PlaylistItemFactory.cs
@@ -8,9 +8,9 @@ using MediaBrowser.Model.Session;
namespace Emby.Dlna.PlayTo
{
- public class PlaylistItemFactory
+ public static class PlaylistItemFactory
{
- public PlaylistItem Create(Photo item, DeviceProfile profile)
+ public static PlaylistItem Create(Photo item, DeviceProfile profile)
{
var playlistItem = new PlaylistItem
{
diff --git a/Emby.Dlna/PlayTo/SsdpHttpClient.cs b/Emby.Dlna/PlayTo/SsdpHttpClient.cs
index c8c36fc97..557bc69a7 100644
--- a/Emby.Dlna/PlayTo/SsdpHttpClient.cs
+++ b/Emby.Dlna/PlayTo/SsdpHttpClient.cs
@@ -4,7 +4,6 @@ using System;
using System.Globalization;
using System.IO;
using System.Net.Http;
-using System.Net.Http.Headers;
using System.Net.Mime;
using System.Text;
using System.Threading;
@@ -45,7 +44,7 @@ namespace Emby.Dlna.PlayTo
header,
cancellationToken)
.ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(stream, Encoding.UTF8);
return XDocument.Parse(
await reader.ReadToEndAsync().ConfigureAwait(false),
@@ -60,7 +59,7 @@ namespace Emby.Dlna.PlayTo
return serviceUrl;
}
- if (!serviceUrl.StartsWith("/", StringComparison.Ordinal))
+ if (!serviceUrl.StartsWith('/'))
{
serviceUrl = "/" + serviceUrl;
}
@@ -94,7 +93,7 @@ namespace Emby.Dlna.PlayTo
options.Headers.UserAgent.ParseAdd(USERAGENT);
options.Headers.TryAddWithoutValidation("FriendlyName.DLNA.ORG", FriendlyName);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = new StreamReader(stream, Encoding.UTF8);
return XDocument.Parse(
await reader.ReadToEndAsync().ConfigureAwait(false),
diff --git a/Emby.Dlna/PlayTo/TransportCommands.cs b/Emby.Dlna/PlayTo/TransportCommands.cs
index fda17a8b4..0865968ad 100644
--- a/Emby.Dlna/PlayTo/TransportCommands.cs
+++ b/Emby.Dlna/PlayTo/TransportCommands.cs
@@ -78,7 +78,7 @@ namespace Emby.Dlna.PlayTo
private static StateVariable FromXml(XElement container)
{
- var allowedValues = new List<string>();
+ var allowedValues = Array.Empty<string>();
var element = container.Descendants(UPnpNamespaces.Svc + "allowedValueList")
.FirstOrDefault();
@@ -86,14 +86,14 @@ namespace Emby.Dlna.PlayTo
{
var values = element.Descendants(UPnpNamespaces.Svc + "allowedValue");
- allowedValues.AddRange(values.Select(child => child.Value));
+ allowedValues = values.Select(child => child.Value).ToArray();
}
return new StateVariable
{
Name = container.GetValue(UPnpNamespaces.Svc + "name"),
DataType = container.GetValue(UPnpNamespaces.Svc + "dataType"),
- AllowedValues = allowedValues.ToArray()
+ AllowedValues = allowedValues
};
}
@@ -103,12 +103,12 @@ namespace Emby.Dlna.PlayTo
foreach (var arg in action.ArgumentList)
{
- if (arg.Direction == "out")
+ if (string.Equals(arg.Direction, "out", StringComparison.Ordinal))
{
continue;
}
- if (arg.Name == "InstanceID")
+ if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal))
{
stateString += BuildArgumentXml(arg, "0");
}
@@ -127,12 +127,12 @@ namespace Emby.Dlna.PlayTo
foreach (var arg in action.ArgumentList)
{
- if (arg.Direction == "out")
+ if (string.Equals(arg.Direction, "out", StringComparison.Ordinal))
{
continue;
}
- if (arg.Name == "InstanceID")
+ if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal))
{
stateString += BuildArgumentXml(arg, "0");
}
@@ -151,7 +151,7 @@ namespace Emby.Dlna.PlayTo
foreach (var arg in action.ArgumentList)
{
- if (arg.Name == "InstanceID")
+ if (string.Equals(arg.Name, "InstanceID", StringComparison.Ordinal))
{
stateString += BuildArgumentXml(arg, "0");
}
diff --git a/Emby.Dlna/Properties/AssemblyInfo.cs b/Emby.Dlna/Properties/AssemblyInfo.cs
index a2c1e0db8..606ffcf4f 100644
--- a/Emby.Dlna/Properties/AssemblyInfo.cs
+++ b/Emby.Dlna/Properties/AssemblyInfo.cs
@@ -1,5 +1,6 @@
using System.Reflection;
using System.Resources;
+using System.Runtime.CompilerServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
@@ -13,6 +14,7 @@ using System.Resources;
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
[assembly: NeutralResourcesLanguage("en")]
+[assembly: InternalsVisibleTo("Jellyfin.Dlna.Tests")]
// Version information for an assembly consists of the following four values:
//
diff --git a/Emby.Dlna/Server/DescriptionXmlBuilder.cs b/Emby.Dlna/Server/DescriptionXmlBuilder.cs
index bca9e81cd..09525aae4 100644
--- a/Emby.Dlna/Server/DescriptionXmlBuilder.cs
+++ b/Emby.Dlna/Server/DescriptionXmlBuilder.cs
@@ -40,8 +40,6 @@ namespace Emby.Dlna.Server
_serverId = serverId;
}
- private static bool EnableAbsoluteUrls => false;
-
public string GetXml()
{
var builder = new StringBuilder();
@@ -75,13 +73,6 @@ namespace Emby.Dlna.Server
builder.Append("<minor>0</minor>");
builder.Append("</specVersion>");
- if (!EnableAbsoluteUrls)
- {
- builder.Append("<URLBase>")
- .Append(SecurityElement.Escape(_serverAddress))
- .Append("</URLBase>");
- }
-
AppendDeviceInfo(builder);
builder.Append("</root>");
@@ -257,14 +248,7 @@ namespace Emby.Dlna.Server
return string.Empty;
}
- url = url.TrimStart('/');
-
- url = "/dlna/" + _serverUdn + "/" + url;
-
- if (EnableAbsoluteUrls)
- {
- url = _serverAddress.TrimEnd('/') + url;
- }
+ url = _serverAddress.TrimEnd('/') + "/dlna/" + _serverUdn + "/" + url.TrimStart('/');
return SecurityElement.Escape(url);
}
diff --git a/Emby.Naming/Subtitles/SubtitleParser.cs b/Emby.Naming/Subtitles/SubtitleParser.cs
index e87245251..a19340ef6 100644
--- a/Emby.Naming/Subtitles/SubtitleParser.cs
+++ b/Emby.Naming/Subtitles/SubtitleParser.cs
@@ -60,7 +60,7 @@ namespace Emby.Naming.Subtitles
private string[] GetFlags(string path)
{
- // Note: the tags need be be surrounded be either a space ( ), hyphen -, dot . or underscore _.
+ // Note: the tags need be surrounded be either a space ( ), hyphen -, dot . or underscore _.
var file = Path.GetFileName(path);
diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs
index 19cc491cf..fd1677473 100644
--- a/Emby.Naming/Video/VideoListResolver.cs
+++ b/Emby.Naming/Video/VideoListResolver.cs
@@ -30,7 +30,7 @@ namespace Emby.Naming.Video
/// </summary>
/// <param name="files">List of related video files.</param>
/// <param name="supportMultiVersion">Indication we should consider multi-versions of content.</param>
- /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files togeather when related.</returns>
+ /// <returns>Returns enumerable of <see cref="VideoInfo"/> which groups files together when related.</returns>
public IEnumerable<VideoInfo> Resolve(List<FileSystemMetadata> files, bool supportMultiVersion = true)
{
var videoResolver = new VideoResolver(_options);
@@ -227,7 +227,11 @@ namespace Emby.Naming.Video
testFilename = cleanName.ToString();
}
- testFilename = testFilename.Substring(folderName.Length).Trim();
+ if (folderName.Length <= testFilename.Length)
+ {
+ testFilename = testFilename.Substring(folderName.Length).Trim();
+ }
+
return string.IsNullOrEmpty(testFilename)
|| testFilename[0].Equals('-')
|| testFilename[0].Equals('_')
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index ad3c19618..d74ea0352 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -1,14 +1,12 @@
#pragma warning disable CS1591
using System;
-using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
-using System.Net.Sockets;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
@@ -46,10 +44,11 @@ using Emby.Server.Implementations.SyncPlay;
using Emby.Server.Implementations.TV;
using Emby.Server.Implementations.Updates;
using Jellyfin.Api.Helpers;
+using Jellyfin.Networking.Configuration;
+using Jellyfin.Networking.Manager;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Events;
-using MediaBrowser.Common.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Common.Updates;
@@ -82,7 +81,6 @@ using MediaBrowser.Controller.SyncPlay;
using MediaBrowser.Controller.TV;
using MediaBrowser.LocalMetadata.Savers;
using MediaBrowser.MediaEncoding.BdInfo;
-using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Cryptography;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Globalization;
@@ -94,10 +92,10 @@ using MediaBrowser.Model.System;
using MediaBrowser.Model.Tasks;
using MediaBrowser.Providers.Chapters;
using MediaBrowser.Providers.Manager;
-using MediaBrowser.Providers.Plugins.TheTvdb;
using MediaBrowser.Providers.Plugins.Tmdb;
using MediaBrowser.Providers.Subtitles;
using MediaBrowser.XbmcMetadata.Providers;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -118,14 +116,12 @@ namespace Emby.Server.Implementations
private static readonly string[] _relevantEnvVarPrefixes = { "JELLYFIN_", "DOTNET_", "ASPNETCORE_" };
private readonly IFileSystem _fileSystemManager;
- private readonly INetworkManager _networkManager;
private readonly IXmlSerializer _xmlSerializer;
private readonly IJsonSerializer _jsonSerializer;
private readonly IStartupOptions _startupOptions;
private IMediaEncoder _mediaEncoder;
private ISessionManager _sessionManager;
- private IHttpClientFactory _httpClientFactory;
private string[] _urlPrefixes;
/// <summary>
@@ -160,6 +156,11 @@ namespace Emby.Server.Implementations
}
/// <summary>
+ /// Gets the <see cref="INetworkManager"/> singleton instance.
+ /// </summary>
+ public INetworkManager NetManager { get; internal set; }
+
+ /// <summary>
/// Occurs when [has pending restart changed].
/// </summary>
public event EventHandler HasPendingRestartChanged;
@@ -182,6 +183,8 @@ namespace Emby.Server.Implementations
private IPlugin[] _plugins;
+ private IReadOnlyList<LocalPlugin> _pluginsManifests;
+
/// <summary>
/// Gets the plugins.
/// </summary>
@@ -211,7 +214,7 @@ namespace Emby.Server.Implementations
private readonly List<IDisposable> _disposableParts = new List<IDisposable>();
/// <summary>
- /// Gets the configuration manager.
+ /// Gets or sets the configuration manager.
/// </summary>
/// <value>The configuration manager.</value>
protected IConfigurationManager ConfigurationManager { get; set; }
@@ -244,14 +247,12 @@ namespace Emby.Server.Implementations
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
/// <param name="options">Instance of the <see cref="IStartupOptions"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
- /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
/// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param>
public ApplicationHost(
IServerApplicationPaths applicationPaths,
ILoggerFactory loggerFactory,
IStartupOptions options,
IFileSystem fileSystem,
- INetworkManager networkManager,
IServiceCollection serviceCollection)
{
_xmlSerializer = new MyXmlSerializer();
@@ -259,14 +260,17 @@ namespace Emby.Server.Implementations
ServiceCollection = serviceCollection;
- _networkManager = networkManager;
- networkManager.LocalSubnetsFn = GetConfiguredLocalSubnets;
-
ApplicationPaths = applicationPaths;
LoggerFactory = loggerFactory;
_fileSystemManager = fileSystem;
ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager);
+ // Have to migrate settings here as migration subsystem not yet initialised.
+ MigrateNetworkConfiguration();
+
+ // Have to pre-register the NetworkConfigurationFactory, as the configuration sub-system is not yet initialised.
+ ConfigurationManager.RegisterConfiguration<NetworkConfigurationFactory>();
+ NetManager = new NetworkManager((IServerConfigurationManager)ConfigurationManager, LoggerFactory.CreateLogger<NetworkManager>());
Logger = LoggerFactory.CreateLogger<ApplicationHost>();
@@ -280,8 +284,6 @@ namespace Emby.Server.Implementations
fileSystem.AddShortcutHandler(new MbLinkShortcutHandler(fileSystem));
- _networkManager.NetworkChanged += OnNetworkChanged;
-
CertificateInfo = new CertificateInfo
{
Path = ServerConfigurationManager.Configuration.CertificatePath,
@@ -294,6 +296,22 @@ namespace Emby.Server.Implementations
ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString;
}
+ /// <summary>
+ /// Temporary function to migration network settings out of system.xml and into network.xml.
+ /// TODO: remove at the point when a fixed migration path has been decided upon.
+ /// </summary>
+ private void MigrateNetworkConfiguration()
+ {
+ string path = Path.Combine(ConfigurationManager.CommonApplicationPaths.ConfigurationDirectoryPath, "network.xml");
+ if (!File.Exists(path))
+ {
+ var networkSettings = new NetworkConfiguration();
+ ClassMigrationHelper.CopyProperties(ServerConfigurationManager.Configuration, networkSettings);
+ _xmlSerializer.SerializeToFile(networkSettings, path);
+ Logger?.LogDebug("Successfully migrated network settings.");
+ }
+ }
+
public string ExpandVirtualPath(string path)
{
var appPaths = ApplicationPaths;
@@ -310,16 +328,6 @@ namespace Emby.Server.Implementations
.Replace(appPaths.InternalMetadataPath, appPaths.VirtualInternalMetadataPath, StringComparison.OrdinalIgnoreCase);
}
- private string[] GetConfiguredLocalSubnets()
- {
- return ServerConfigurationManager.Configuration.LocalNetworkSubnets;
- }
-
- private void OnNetworkChanged(object sender, EventArgs e)
- {
- _validAddressResults.Clear();
- }
-
/// <inheritdoc />
public Version ApplicationVersion { get; }
@@ -486,14 +494,15 @@ namespace Emby.Server.Implementations
/// <inheritdoc/>
public void Init()
{
- HttpPort = ServerConfigurationManager.Configuration.HttpServerPortNumber;
- HttpsPort = ServerConfigurationManager.Configuration.HttpsPortNumber;
+ var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration();
+ HttpPort = networkConfiguration.HttpServerPortNumber;
+ HttpsPort = networkConfiguration.HttpsPortNumber;
// Safeguard against invalid configuration
if (HttpPort == HttpsPort)
{
- HttpPort = ServerConfiguration.DefaultHttpPort;
- HttpsPort = ServerConfiguration.DefaultHttpsPort;
+ HttpPort = NetworkConfiguration.DefaultHttpPort;
+ HttpsPort = NetworkConfiguration.DefaultHttpsPort;
}
DiscoverTypes();
@@ -520,12 +529,9 @@ namespace Emby.Server.Implementations
ServiceCollection.AddSingleton<IJsonSerializer, JsonSerializer>();
ServiceCollection.AddSingleton(_fileSystemManager);
- ServiceCollection.AddSingleton<TvdbClientManager>();
ServiceCollection.AddSingleton<TmdbClientManager>();
- ServiceCollection.AddSingleton(_networkManager);
-
- ServiceCollection.AddSingleton<IIsoManager, IsoManager>();
+ ServiceCollection.AddSingleton(NetManager);
ServiceCollection.AddSingleton<ITaskManager, TaskManager>();
@@ -627,7 +633,6 @@ namespace Emby.Server.Implementations
ServiceCollection.AddSingleton<ISubtitleEncoder, MediaBrowser.MediaEncoding.Subtitles.SubtitleEncoder>();
- ServiceCollection.AddSingleton<IResourceFileManager, ResourceFileManager>();
ServiceCollection.AddSingleton<EncodingHelper>();
ServiceCollection.AddSingleton<IAttachmentExtractor, MediaBrowser.MediaEncoding.Attachments.AttachmentExtractor>();
@@ -649,7 +654,6 @@ namespace Emby.Server.Implementations
_mediaEncoder = Resolve<IMediaEncoder>();
_sessionManager = Resolve<ISessionManager>();
- _httpClientFactory = Resolve<IHttpClientFactory>();
((AuthenticationRepository)Resolve<IAuthenticationRepository>()).Initialize();
@@ -770,17 +774,27 @@ namespace Emby.Server.Implementations
if (Plugins != null)
{
- var pluginBuilder = new StringBuilder();
-
foreach (var plugin in Plugins)
{
- pluginBuilder.Append(plugin.Name)
- .Append(' ')
- .Append(plugin.Version)
- .AppendLine();
- }
+ if (_pluginsManifests != null && plugin is IPluginAssembly assemblyPlugin)
+ {
+ // Ensure the version number matches the Plugin Manifest information.
+ foreach (var item in _pluginsManifests)
+ {
+ if (Path.GetDirectoryName(plugin.AssemblyFilePath).Equals(item.Path, StringComparison.OrdinalIgnoreCase))
+ {
+ // Update version number to that of the manifest.
+ assemblyPlugin.SetAttributes(
+ plugin.AssemblyFilePath,
+ Path.Combine(ApplicationPaths.PluginsPath, Path.GetFileNameWithoutExtension(plugin.AssemblyFilePath)),
+ item.Version);
+ break;
+ }
+ }
+ }
- Logger.LogInformation("Plugins: {Plugins}", pluginBuilder.ToString());
+ Logger.LogInformation("Loaded plugin: {PluginName} {PluginVersion}", plugin.Name, plugin.Version);
+ }
}
_urlPrefixes = GetUrlPrefixes().ToArray();
@@ -808,8 +822,6 @@ namespace Emby.Server.Implementations
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
Resolve<INotificationManager>().AddParts(GetExports<INotificationService>(), GetExports<INotificationTypeFactory>());
-
- Resolve<IIsoManager>().AddParts(GetExports<IIsoMounter>());
}
/// <summary>
@@ -904,9 +916,10 @@ namespace Emby.Server.Implementations
// Don't do anything if these haven't been set yet
if (HttpPort != 0 && HttpsPort != 0)
{
+ var networkConfiguration = ServerConfigurationManager.GetNetworkConfiguration();
// Need to restart if ports have changed
- if (ServerConfigurationManager.Configuration.HttpServerPortNumber != HttpPort ||
- ServerConfigurationManager.Configuration.HttpsPortNumber != HttpsPort)
+ if (networkConfiguration.HttpServerPortNumber != HttpPort ||
+ networkConfiguration.HttpsPortNumber != HttpsPort)
{
if (ServerConfigurationManager.Configuration.IsPortAuthorized)
{
@@ -1036,7 +1049,7 @@ namespace Emby.Server.Implementations
metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1];
int versionIndex = dir.LastIndexOf('_');
- if (versionIndex != -1 && Version.TryParse(dir.Substring(versionIndex + 1), out Version parsedVersion))
+ if (versionIndex != -1 && Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out Version parsedVersion))
{
// Versioned folder.
versions.Add(new LocalPlugin(Guid.Empty, metafile, parsedVersion, dir));
@@ -1070,7 +1083,6 @@ namespace Emby.Server.Implementations
if (!string.IsNullOrEmpty(lastName) && cleanup)
{
// Attempt a cleanup of old folders.
- versions.RemoveAt(x);
try
{
Logger.LogDebug("Deleting {Path}", versions[x].Path);
@@ -1080,6 +1092,8 @@ namespace Emby.Server.Implementations
{
Logger.LogWarning(e, "Unable to delete {Path}", versions[x].Path);
}
+
+ versions.RemoveAt(x);
}
}
@@ -1094,7 +1108,8 @@ namespace Emby.Server.Implementations
{
if (Directory.Exists(ApplicationPaths.PluginsPath))
{
- foreach (var plugin in GetLocalPlugins(ApplicationPaths.PluginsPath))
+ _pluginsManifests = GetLocalPlugins(ApplicationPaths.PluginsPath).ToList();
+ foreach (var plugin in _pluginsManifests)
{
foreach (var file in plugin.DllFiles)
{
@@ -1148,6 +1163,9 @@ namespace Emby.Server.Implementations
// Xbmc
yield return typeof(ArtistNfoProvider).Assembly;
+ // Network
+ yield return typeof(NetworkManager).Assembly;
+
foreach (var i in GetAssembliesWithPartsInternal())
{
yield return i;
@@ -1159,13 +1177,10 @@ namespace Emby.Server.Implementations
/// <summary>
/// Gets the system status.
/// </summary>
- /// <param name="cancellationToken">The cancellation token.</param>
+ /// <param name="source">Where this request originated.</param>
/// <returns>SystemInfo.</returns>
- public async Task<SystemInfo> GetSystemInfo(CancellationToken cancellationToken)
+ public SystemInfo GetSystemInfo(IPAddress source)
{
- var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
- var transcodingTempPath = ConfigurationManager.GetTranscodePath();
-
return new SystemInfo
{
HasPendingRestart = HasPendingRestart,
@@ -1185,9 +1200,9 @@ namespace Emby.Server.Implementations
CanSelfRestart = CanSelfRestart,
CanLaunchWebBrowser = CanLaunchWebBrowser,
HasUpdateAvailable = HasUpdateAvailable,
- TranscodingTempPath = transcodingTempPath,
+ TranscodingTempPath = ConfigurationManager.GetTranscodePath(),
ServerName = FriendlyName,
- LocalAddress = localAddress,
+ LocalAddress = GetSmartApiUrl(source),
SupportsLibraryMonitor = true,
EncoderLocation = _mediaEncoder.EncoderLocation,
SystemArchitecture = RuntimeInformation.OSArchitecture,
@@ -1196,14 +1211,12 @@ namespace Emby.Server.Implementations
}
public IEnumerable<WakeOnLanInfo> GetWakeOnLanInfo()
- => _networkManager.GetMacAddresses()
+ => NetManager.GetMacAddresses()
.Select(i => new WakeOnLanInfo(i))
.ToList();
- public async Task<PublicSystemInfo> GetPublicSystemInfo(CancellationToken cancellationToken)
+ public PublicSystemInfo GetPublicSystemInfo(IPAddress source)
{
- var localAddress = await GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
-
return new PublicSystemInfo
{
Version = ApplicationVersionString,
@@ -1211,193 +1224,98 @@ namespace Emby.Server.Implementations
Id = SystemId,
OperatingSystem = OperatingSystem.Id.ToString(),
ServerName = FriendlyName,
- LocalAddress = localAddress,
+ LocalAddress = GetSmartApiUrl(source),
StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted
};
}
/// <inheritdoc/>
- public bool ListenWithHttps => Certificate != null && ServerConfigurationManager.Configuration.EnableHttps;
+ public bool ListenWithHttps => Certificate != null && ServerConfigurationManager.GetNetworkConfiguration().EnableHttps;
/// <inheritdoc/>
- public async Task<string> GetLocalApiUrl(CancellationToken cancellationToken)
+ public string GetSmartApiUrl(IPAddress ipAddress, int? port = null)
{
- try
+ // Published server ends with a /
+ if (_startupOptions.PublishedServerUrl != null)
{
- // Return the first matched address, if found, or the first known local address
- var addresses = await GetLocalIpAddressesInternal(false, 1, cancellationToken).ConfigureAwait(false);
- if (addresses.Count == 0)
- {
- return null;
- }
-
- return GetLocalApiUrl(addresses[0]);
+ // Published server ends with a '/', so we need to remove it.
+ return _startupOptions.PublishedServerUrl.ToString().Trim('/');
}
- catch (Exception ex)
+
+ string smart = NetManager.GetBindInterface(ipAddress, out port);
+ // If the smartAPI doesn't start with http then treat it as a host or ip.
+ if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
- Logger.LogError(ex, "Error getting local Ip address information");
+ return smart.Trim('/');
}
- return null;
+ return GetLocalApiUrl(smart.Trim('/'), null, port);
}
- /// <summary>
- /// Removes the scope id from IPv6 addresses.
- /// </summary>
- /// <param name="address">The IPv6 address.</param>
- /// <returns>The IPv6 address without the scope id.</returns>
- private ReadOnlySpan<char> RemoveScopeId(ReadOnlySpan<char> address)
+ /// <inheritdoc/>
+ public string GetSmartApiUrl(HttpRequest request, int? port = null)
{
- var index = address.IndexOf('%');
- if (index == -1)
+ // Published server ends with a /
+ if (_startupOptions.PublishedServerUrl != null)
{
- return address;
+ // Published server ends with a '/', so we need to remove it.
+ return _startupOptions.PublishedServerUrl.ToString().Trim('/');
}
- return address.Slice(0, index);
- }
-
- /// <inheritdoc />
- public string GetLocalApiUrl(IPAddress ipAddress)
- {
- if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6)
+ string smart = NetManager.GetBindInterface(request, out port);
+ // If the smartAPI doesn't start with http then treat it as a host or ip.
+ if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
- var str = RemoveScopeId(ipAddress.ToString());
- Span<char> span = new char[str.Length + 2];
- span[0] = '[';
- str.CopyTo(span.Slice(1));
- span[^1] = ']';
-
- return GetLocalApiUrl(span);
+ return smart.Trim('/');
}
- return GetLocalApiUrl(ipAddress.ToString());
- }
-
- /// <inheritdoc/>
- public string GetLoopbackHttpApiUrl()
- {
- return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort);
+ return GetLocalApiUrl(smart.Trim('/'), request.Scheme, port);
}
/// <inheritdoc/>
- public string GetLocalApiUrl(ReadOnlySpan<char> host, string scheme = null, int? port = null)
+ public string GetSmartApiUrl(string hostname, int? port = null)
{
- // NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does
- // not. For consistency, always trim the trailing slash.
- return new UriBuilder
+ // Published server ends with a /
+ if (_startupOptions.PublishedServerUrl != null)
{
- Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp),
- Host = host.ToString(),
- Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort),
- Path = ServerConfigurationManager.Configuration.BaseUrl
- }.ToString().TrimEnd('/');
- }
-
- public Task<List<IPAddress>> GetLocalIpAddresses(CancellationToken cancellationToken)
- {
- return GetLocalIpAddressesInternal(true, 0, cancellationToken);
- }
-
- private async Task<List<IPAddress>> GetLocalIpAddressesInternal(bool allowLoopback, int limit, CancellationToken cancellationToken)
- {
- var addresses = ServerConfigurationManager
- .Configuration
- .LocalNetworkAddresses
- .Select(x => NormalizeConfiguredLocalAddress(x))
- .Where(i => i != null)
- .ToList();
-
- if (addresses.Count == 0)
- {
- addresses.AddRange(_networkManager.GetLocalIpAddresses());
+ // Published server ends with a '/', so we need to remove it.
+ return _startupOptions.PublishedServerUrl.ToString().Trim('/');
}
- var resultList = new List<IPAddress>();
+ string smart = NetManager.GetBindInterface(hostname, out port);
- foreach (var address in addresses)
+ // If the smartAPI doesn't start with http then treat it as a host or ip.
+ if (smart.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
- if (!allowLoopback)
- {
- if (address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback))
- {
- continue;
- }
- }
-
- if (await IsLocalIpAddressValidAsync(address, cancellationToken).ConfigureAwait(false))
- {
- resultList.Add(address);
-
- if (limit > 0 && resultList.Count >= limit)
- {
- return resultList;
- }
- }
+ return smart.Trim('/');
}
- return resultList;
+ return GetLocalApiUrl(smart.Trim('/'), null, port);
}
- public IPAddress NormalizeConfiguredLocalAddress(ReadOnlySpan<char> address)
+ /// <inheritdoc/>
+ public string GetLoopbackHttpApiUrl()
{
- var index = address.Trim('/').IndexOf('/');
- if (index != -1)
- {
- address = address.Slice(index + 1);
- }
-
- if (IPAddress.TryParse(address.Trim('/'), out IPAddress result))
+ if (NetManager.IsIP6Enabled)
{
- return result;
+ return GetLocalApiUrl("::1", Uri.UriSchemeHttp, HttpPort);
}
- return null;
+ return GetLocalApiUrl("127.0.0.1", Uri.UriSchemeHttp, HttpPort);
}
- private readonly ConcurrentDictionary<string, bool> _validAddressResults = new ConcurrentDictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
-
- private async Task<bool> IsLocalIpAddressValidAsync(IPAddress address, CancellationToken cancellationToken)
+ /// <inheritdoc/>
+ public string GetLocalApiUrl(string host, string scheme = null, int? port = null)
{
- if (address.Equals(IPAddress.Loopback)
- || address.Equals(IPAddress.IPv6Loopback))
- {
- return true;
- }
-
- var apiUrl = GetLocalApiUrl(address) + "/system/ping";
-
- if (_validAddressResults.TryGetValue(apiUrl, out var cachedResult))
- {
- return cachedResult;
- }
-
- try
- {
- using var request = new HttpRequestMessage(HttpMethod.Post, apiUrl);
- using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
- .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
-
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
- var result = await System.Text.Json.JsonSerializer.DeserializeAsync<string>(stream, JsonDefaults.GetOptions(), cancellationToken).ConfigureAwait(false);
- var valid = string.Equals(Name, result, StringComparison.OrdinalIgnoreCase);
-
- _validAddressResults.AddOrUpdate(apiUrl, valid, (k, v) => valid);
- Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, valid);
- return valid;
- }
- catch (OperationCanceledException)
- {
- Logger.LogDebug("Ping test result to {0}. Success: {1}", apiUrl, "Cancelled");
- throw;
- }
- catch (Exception ex)
+ // NOTE: If no BaseUrl is set then UriBuilder appends a trailing slash, but if there is no BaseUrl it does
+ // not. For consistency, always trim the trailing slash.
+ return new UriBuilder
{
- Logger.LogDebug(ex, "Ping test result to {0}. Success: {1}", apiUrl, false);
-
- _validAddressResults.AddOrUpdate(apiUrl, false, (k, v) => false);
- return false;
- }
+ Scheme = scheme ?? (ListenWithHttps ? Uri.UriSchemeHttps : Uri.UriSchemeHttp),
+ Host = host,
+ Port = port ?? (ListenWithHttps ? HttpsPort : HttpPort),
+ Path = ServerConfigurationManager.GetNetworkConfiguration().BaseUrl
+ }.ToString().TrimEnd('/');
}
public string FriendlyName =>
diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs
index 19045b72b..57684a429 100644
--- a/Emby.Server.Implementations/Channels/ChannelManager.cs
+++ b/Emby.Server.Implementations/Channels/ChannelManager.cs
@@ -540,18 +540,18 @@ namespace Emby.Server.Implementations.Channels
{
IncludeItemTypes = new[] { nameof(Channel) },
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) }
- }).Select(i => GetChannelFeatures(i.ToString("N", CultureInfo.InvariantCulture))).ToArray();
+ }).Select(i => GetChannelFeatures(i)).ToArray();
}
/// <inheritdoc />
- public ChannelFeatures GetChannelFeatures(string id)
+ public ChannelFeatures GetChannelFeatures(Guid? id)
{
- if (string.IsNullOrEmpty(id))
+ if (!id.HasValue)
{
throw new ArgumentNullException(nameof(id));
}
- var channel = GetChannel(id);
+ var channel = GetChannel(id.Value);
var channelProvider = GetChannelProvider(channel);
return GetChannelFeaturesDto(channel, channelProvider, channelProvider.GetChannelFeatures());
@@ -634,7 +634,7 @@ namespace Emby.Server.Implementations.Channels
{
var channels = GetAllChannels().Where(i => i is ISupportsLatestMedia).ToArray();
- if (query.ChannelIds.Length > 0)
+ if (query.ChannelIds.Count > 0)
{
// Avoid implicitly captured closure
var ids = query.ChannelIds;
diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs
index 70a6df977..1af301ceb 100644
--- a/Emby.Server.Implementations/Data/SqliteExtensions.cs
+++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs
@@ -107,20 +107,6 @@ namespace Emby.Server.Implementations.Data
return null;
}
- public static void Attach(SQLiteDatabaseConnection db, string path, string alias)
- {
- var commandText = string.Format(
- CultureInfo.InvariantCulture,
- "attach @path as {0};",
- alias);
-
- using (var statement = db.PrepareStatement(commandText))
- {
- statement.TryBind("@path", path);
- statement.MoveNext();
- }
- }
-
public static bool IsDBNull(this IReadOnlyList<IResultSetValue> result, int index)
{
return result[index].SQLiteType == SQLiteType.Null;
diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
index 638c7a9b4..6e1f2feae 100644
--- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs
+++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs
@@ -3611,12 +3611,12 @@ namespace Emby.Server.Implementations.Data
whereClauses.Add($"type in ({inClause})");
}
- if (query.ChannelIds.Length == 1)
+ if (query.ChannelIds.Count == 1)
{
whereClauses.Add("ChannelId=@ChannelId");
statement?.TryBind("@ChannelId", query.ChannelIds[0].ToString("N", CultureInfo.InvariantCulture));
}
- else if (query.ChannelIds.Length > 1)
+ else if (query.ChannelIds.Count > 1)
{
var inClause = string.Join(",", query.ChannelIds.Select(i => "'" + i.ToString("N", CultureInfo.InvariantCulture) + "'"));
whereClauses.Add($"ChannelId in ({inClause})");
@@ -4076,7 +4076,7 @@ namespace Emby.Server.Implementations.Data
whereClauses.Add(clause);
}
- if (query.GenreIds.Length > 0)
+ if (query.GenreIds.Count > 0)
{
var clauses = new List<string>();
var index = 0;
@@ -4097,7 +4097,7 @@ namespace Emby.Server.Implementations.Data
whereClauses.Add(clause);
}
- if (query.Genres.Length > 0)
+ if (query.Genres.Count > 0)
{
var clauses = new List<string>();
var index = 0;
@@ -4519,17 +4519,17 @@ namespace Emby.Server.Implementations.Data
if (query.HasImdbId.HasValue)
{
- whereClauses.Add("ProviderIds like '%imdb=%'");
+ whereClauses.Add(GetProviderIdClause(query.HasImdbId.Value, "imdb"));
}
if (query.HasTmdbId.HasValue)
{
- whereClauses.Add("ProviderIds like '%tmdb=%'");
+ whereClauses.Add(GetProviderIdClause(query.HasTmdbId.Value, "tmdb"));
}
if (query.HasTvdbId.HasValue)
{
- whereClauses.Add("ProviderIds like '%tvdb=%'");
+ whereClauses.Add(GetProviderIdClause(query.HasTvdbId.Value, "tvdb"));
}
var includedItemByNameTypes = GetItemByNameTypesInQuery(query).SelectMany(MapIncludeItemTypes).ToList();
@@ -4769,6 +4769,21 @@ namespace Emby.Server.Implementations.Data
return whereClauses;
}
+ /// <summary>
+ /// Formats a where clause for the specified provider.
+ /// </summary>
+ /// <param name="includeResults">Whether or not to include items with this provider's ids.</param>
+ /// <param name="provider">Provider name.</param>
+ /// <returns>Formatted SQL clause.</returns>
+ private string GetProviderIdClause(bool includeResults, string provider)
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "ProviderIds {0} like '%{1}=%'",
+ includeResults ? string.Empty : "not",
+ provider);
+ }
+
private List<string> GetItemByNameTypesInQuery(InternalItemsQuery query)
{
var list = new List<string>();
@@ -5022,13 +5037,6 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
var commandText = new StringBuilder("select Distinct p.Name from People p");
- if (query.User != null && query.IsFavorite.HasValue)
- {
- commandText.Append(" LEFT JOIN TypedBaseItems tbi ON tbi.Name=p.Name AND tbi.Type='");
- commandText.Append(typeof(Person).FullName);
- commandText.Append("' LEFT JOIN UserDatas ON tbi.UserDataKey=key AND userId=@UserId");
- }
-
var whereClauses = GetPeopleWhereClauses(query, null);
if (whereClauses.Count != 0)
@@ -5109,6 +5117,16 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
{
var whereClauses = new List<string>();
+ if (query.User != null && query.IsFavorite.HasValue)
+ {
+ whereClauses.Add(@"p.Name IN (
+SELECT Name FROM TypedBaseItems WHERE UserDataKey IN (
+SELECT key FROM UserDatas WHERE isFavorite=@IsFavorite AND userId=@UserId)
+AND Type = @InternalPersonType)");
+ statement?.TryBind("@IsFavorite", query.IsFavorite.Value);
+ statement?.TryBind("@InternalPersonType", typeof(Person).FullName);
+ }
+
if (!query.ItemId.Equals(Guid.Empty))
{
whereClauses.Add("ItemId=@ItemId");
@@ -5161,12 +5179,6 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
statement?.TryBind("@NameContains", "%" + query.NameContains + "%");
}
- if (query.IsFavorite.HasValue)
- {
- whereClauses.Add("isFavorite=@IsFavorite");
- statement?.TryBind("@IsFavorite", query.IsFavorite.Value);
- }
-
if (query.User != null)
{
statement?.TryBind("@UserId", query.User.InternalId);
diff --git a/Emby.Server.Implementations/Devices/DeviceManager.cs b/Emby.Server.Implementations/Devices/DeviceManager.cs
index f98c694c4..da5047d24 100644
--- a/Emby.Server.Implementations/Devices/DeviceManager.cs
+++ b/Emby.Server.Implementations/Devices/DeviceManager.cs
@@ -1,61 +1,38 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
using System.Linq;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
-using MediaBrowser.Common.Extensions;
-using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Security;
using MediaBrowser.Model.Devices;
using MediaBrowser.Model.Querying;
-using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Session;
-using Microsoft.Extensions.Caching.Memory;
namespace Emby.Server.Implementations.Devices
{
public class DeviceManager : IDeviceManager
{
- private readonly IMemoryCache _memoryCache;
- private readonly IJsonSerializer _json;
private readonly IUserManager _userManager;
- private readonly IServerConfigurationManager _config;
private readonly IAuthenticationRepository _authRepo;
- private readonly object _capabilitiesSyncLock = new object();
+ private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new ();
- public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
-
- public DeviceManager(
- IAuthenticationRepository authRepo,
- IJsonSerializer json,
- IUserManager userManager,
- IServerConfigurationManager config,
- IMemoryCache memoryCache)
+ public DeviceManager(IAuthenticationRepository authRepo, IUserManager userManager)
{
- _json = json;
_userManager = userManager;
- _config = config;
- _memoryCache = memoryCache;
_authRepo = authRepo;
}
+ public event EventHandler<GenericEventArgs<Tuple<string, DeviceOptions>>> DeviceOptionsUpdated;
+
public void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
{
- var path = Path.Combine(GetDevicePath(deviceId), "capabilities.json");
- Directory.CreateDirectory(Path.GetDirectoryName(path));
-
- lock (_capabilitiesSyncLock)
- {
- _memoryCache.Set(deviceId, capabilities);
- _json.SerializeToFile(capabilities, path);
- }
+ _capabilitiesMap[deviceId] = capabilities;
}
public void UpdateDeviceOptions(string deviceId, DeviceOptions options)
@@ -72,33 +49,13 @@ namespace Emby.Server.Implementations.Devices
public ClientCapabilities GetCapabilities(string id)
{
- if (_memoryCache.TryGetValue(id, out ClientCapabilities result))
- {
- return result;
- }
-
- lock (_capabilitiesSyncLock)
- {
- var path = Path.Combine(GetDevicePath(id), "capabilities.json");
- try
- {
- return _json.DeserializeFromFile<ClientCapabilities>(path) ?? new ClientCapabilities();
- }
- catch
- {
- }
- }
-
- return new ClientCapabilities();
+ return _capabilitiesMap.TryGetValue(id, out ClientCapabilities result)
+ ? result
+ : new ClientCapabilities();
}
public DeviceInfo GetDevice(string id)
{
- return GetDevice(id, true);
- }
-
- private DeviceInfo GetDevice(string id, bool includeCapabilities)
- {
var session = _authRepo.Get(new AuthenticationInfoQuery
{
DeviceId = id
@@ -154,16 +111,6 @@ namespace Emby.Server.Implementations.Devices
};
}
- private string GetDevicesPath()
- {
- return Path.Combine(_config.ApplicationPaths.DataPath, "devices");
- }
-
- private string GetDevicePath(string id)
- {
- return Path.Combine(GetDevicesPath(), id.GetMD5().ToString("N", CultureInfo.InvariantCulture));
- }
-
public bool CanAccessDevice(User user, string deviceId)
{
if (user == null)
diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
index d360bb00f..91c4648c6 100644
--- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj
+++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj
@@ -22,7 +22,6 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="IPNetwork2" Version="2.5.226" />
<PackageReference Include="Jellyfin.XmlTv" Version="10.6.2" />
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.2.7" />
<PackageReference Include="Microsoft.AspNetCore.Hosting.Abstractions" Version="2.2.0" />
@@ -37,8 +36,8 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="5.0.0" />
<PackageReference Include="Mono.Nat" Version="3.0.1" />
- <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.4.0" />
- <PackageReference Include="ServiceStack.Text.Core" Version="5.10.0" />
+ <PackageReference Include="prometheus-net.DotNetRuntime" Version="3.4.1" />
+ <PackageReference Include="ServiceStack.Text.Core" Version="5.10.2" />
<PackageReference Include="sharpcompress" Version="0.26.0" />
<PackageReference Include="SQLitePCL.pretty.netstandard" Version="2.1.0" />
<PackageReference Include="DotNet.Glob" Version="3.1.0" />
diff --git a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs
index 2e8cc76d2..14201ead2 100644
--- a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs
+++ b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs
@@ -8,6 +8,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
+using Jellyfin.Networking.Configuration;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Plugins;
@@ -56,7 +57,7 @@ namespace Emby.Server.Implementations.EntryPoints
private string GetConfigIdentifier()
{
const char Separator = '|';
- var config = _config.Configuration;
+ var config = _config.GetNetworkConfiguration();
return new StringBuilder(32)
.Append(config.EnableUPnP).Append(Separator)
@@ -93,7 +94,8 @@ namespace Emby.Server.Implementations.EntryPoints
private void Start()
{
- if (!_config.Configuration.EnableUPnP || !_config.Configuration.EnableRemoteAccess)
+ var config = _config.GetNetworkConfiguration();
+ if (!config.EnableUPnP || !config.EnableRemoteAccess)
{
return;
}
@@ -156,11 +158,12 @@ namespace Emby.Server.Implementations.EntryPoints
private IEnumerable<Task> CreatePortMaps(INatDevice device)
{
- yield return CreatePortMap(device, _appHost.HttpPort, _config.Configuration.PublicPort);
+ var config = _config.GetNetworkConfiguration();
+ yield return CreatePortMap(device, _appHost.HttpPort, config.PublicPort);
if (_appHost.ListenWithHttps)
{
- yield return CreatePortMap(device, _appHost.HttpsPort, _config.Configuration.PublicHttpsPort);
+ yield return CreatePortMap(device, _appHost.HttpsPort, config.PublicHttpsPort);
}
}
diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
index ff64e217a..ae1b51b4c 100644
--- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
+++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -44,7 +45,7 @@ namespace Emby.Server.Implementations.EntryPoints
private readonly List<BaseItem> _itemsAdded = new List<BaseItem>();
private readonly List<BaseItem> _itemsRemoved = new List<BaseItem>();
private readonly List<BaseItem> _itemsUpdated = new List<BaseItem>();
- private readonly Dictionary<Guid, DateTime> _lastProgressMessageTimes = new Dictionary<Guid, DateTime>();
+ private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new ConcurrentDictionary<Guid, DateTime>();
public LibraryChangedNotifier(
ILibraryManager libraryManager,
@@ -98,7 +99,7 @@ namespace Emby.Server.Implementations.EntryPoints
}
}
- _lastProgressMessageTimes[item.Id] = DateTime.UtcNow;
+ _lastProgressMessageTimes.AddOrUpdate(item.Id, key => DateTime.UtcNow, (key, existing) => DateTime.UtcNow);
var dict = new Dictionary<string, string>();
dict["ItemId"] = item.Id.ToString("N", CultureInfo.InvariantCulture);
@@ -140,6 +141,8 @@ namespace Emby.Server.Implementations.EntryPoints
private void OnProviderRefreshCompleted(object sender, GenericEventArgs<BaseItem> e)
{
OnProviderRefreshProgress(sender, new GenericEventArgs<Tuple<BaseItem, double>>(new Tuple<BaseItem, double>(e.Argument, 100)));
+
+ _lastProgressMessageTimes.TryRemove(e.Argument.Id, out DateTime removed);
}
private static bool EnableRefreshMessage(BaseItem item)
diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
index df7a034e8..4a0fc8239 100644
--- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
+++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs
@@ -1,5 +1,6 @@
#pragma warning disable CS1591
+using System;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Net;
@@ -20,9 +21,15 @@ namespace Emby.Server.Implementations.HttpServer.Security
public AuthorizationInfo Authenticate(HttpRequest request)
{
var auth = _authorizationContext.GetAuthorizationInfo(request);
+
+ if (!auth.HasToken)
+ {
+ throw new AuthenticationException("Request does not contain a token.");
+ }
+
if (!auth.IsAuthenticated)
{
- throw new AuthenticationException("Invalid token.");
+ throw new SecurityException("Invalid token.");
}
if (auth.User?.HasPermission(PermissionKind.IsDisabled) ?? false)
diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
index fdf2e3908..d62e2eefe 100644
--- a/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
+++ b/Emby.Server.Implementations/HttpServer/Security/AuthorizationContext.cs
@@ -102,7 +102,8 @@ namespace Emby.Server.Implementations.HttpServer.Security
DeviceId = deviceId,
Version = version,
Token = token,
- IsAuthenticated = false
+ IsAuthenticated = false,
+ HasToken = false
};
if (string.IsNullOrWhiteSpace(token))
@@ -111,6 +112,7 @@ namespace Emby.Server.Implementations.HttpServer.Security
return authInfo;
}
+ authInfo.HasToken = true;
var result = _authRepo.Get(new AuthenticationInfoQuery
{
AccessToken = token
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
index 71ece80a7..d6cf6233e 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs
@@ -2,9 +2,9 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Net.WebSockets;
using System.Threading.Tasks;
-using Jellyfin.Data.Events;
using MediaBrowser.Controller.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
@@ -13,32 +13,23 @@ namespace Emby.Server.Implementations.HttpServer
{
public class WebSocketManager : IWebSocketManager
{
- private readonly Lazy<IEnumerable<IWebSocketListener>> _webSocketListeners;
+ private readonly IWebSocketListener[] _webSocketListeners;
private readonly ILogger<WebSocketManager> _logger;
private readonly ILoggerFactory _loggerFactory;
- private bool _disposed = false;
-
public WebSocketManager(
- Lazy<IEnumerable<IWebSocketListener>> webSocketListeners,
+ IEnumerable<IWebSocketListener> webSocketListeners,
ILogger<WebSocketManager> logger,
ILoggerFactory loggerFactory)
{
- _webSocketListeners = webSocketListeners;
+ _webSocketListeners = webSocketListeners.ToArray();
_logger = logger;
_loggerFactory = loggerFactory;
}
- public event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
-
/// <inheritdoc />
public async Task WebSocketRequestHandler(HttpContext context)
{
- if (_disposed)
- {
- return;
- }
-
try
{
_logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress);
@@ -54,7 +45,13 @@ namespace Emby.Server.Implementations.HttpServer
OnReceive = ProcessWebSocketMessageReceived
};
- WebSocketConnected?.Invoke(this, new GenericEventArgs<IWebSocketConnection>(connection));
+ var tasks = new Task[_webSocketListeners.Length];
+ for (var i = 0; i < _webSocketListeners.Length; ++i)
+ {
+ tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection);
+ }
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
await connection.ProcessAsync().ConfigureAwait(false);
_logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress);
@@ -75,21 +72,13 @@ namespace Emby.Server.Implementations.HttpServer
/// <param name="result">The result.</param>
private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result)
{
- if (_disposed)
- {
- return Task.CompletedTask;
- }
-
- IEnumerable<Task> GetTasks()
+ var tasks = new Task[_webSocketListeners.Length];
+ for (var i = 0; i < _webSocketListeners.Length; ++i)
{
- var listeners = _webSocketListeners.Value;
- foreach (var x in listeners)
- {
- yield return x.ProcessMessageAsync(result);
- }
+ tasks[i] = _webSocketListeners[i].ProcessMessageAsync(result);
}
- return Task.WhenAll(GetTasks());
+ return Task.WhenAll(tasks);
}
}
}
diff --git a/Emby.Server.Implementations/IO/IsoManager.cs b/Emby.Server.Implementations/IO/IsoManager.cs
deleted file mode 100644
index 94e92c2a6..000000000
--- a/Emby.Server.Implementations/IO/IsoManager.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Model.IO;
-
-namespace Emby.Server.Implementations.IO
-{
- /// <summary>
- /// Class IsoManager.
- /// </summary>
- public class IsoManager : IIsoManager
- {
- /// <summary>
- /// The _mounters.
- /// </summary>
- private readonly List<IIsoMounter> _mounters = new List<IIsoMounter>();
-
- /// <summary>
- /// Mounts the specified iso path.
- /// </summary>
- /// <param name="isoPath">The iso path.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns><see creaf="IsoMount" />.</returns>
- public Task<IIsoMount> Mount(string isoPath, CancellationToken cancellationToken)
- {
- if (string.IsNullOrEmpty(isoPath))
- {
- throw new ArgumentNullException(nameof(isoPath));
- }
-
- var mounter = _mounters.FirstOrDefault(i => i.CanMount(isoPath));
-
- if (mounter == null)
- {
- throw new ArgumentException(
- string.Format(
- CultureInfo.InvariantCulture,
- "No mounters are able to mount {0}",
- isoPath));
- }
-
- return mounter.Mount(isoPath, cancellationToken);
- }
-
- /// <summary>
- /// Determines whether this instance can mount the specified path.
- /// </summary>
- /// <param name="path">The path.</param>
- /// <returns><c>true</c> if this instance can mount the specified path; otherwise, <c>false</c>.</returns>
- public bool CanMount(string path)
- {
- return _mounters.Any(i => i.CanMount(path));
- }
-
- /// <summary>
- /// Adds the parts.
- /// </summary>
- /// <param name="mounters">The mounters.</param>
- public void AddParts(IEnumerable<IIsoMounter> mounters)
- {
- _mounters.AddRange(mounters);
- }
- }
-}
diff --git a/Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs b/Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs
new file mode 100644
index 000000000..d4e790c9a
--- /dev/null
+++ b/Emby.Server.Implementations/Library/ImageFetcherPostScanTask.cs
@@ -0,0 +1,130 @@
+using System;
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Events;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Net;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.Library
+{
+ /// <summary>
+ /// A library post scan/refresh task for pre-fetching remote images.
+ /// </summary>
+ public class ImageFetcherPostScanTask : ILibraryPostScanTask
+ {
+ private readonly ILibraryManager _libraryManager;
+ private readonly IProviderManager _providerManager;
+ private readonly ILogger<ImageFetcherPostScanTask> _logger;
+ private readonly SemaphoreSlim _imageFetcherLock;
+
+ private ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)> _queuedItems;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ImageFetcherPostScanTask"/> class.
+ /// </summary>
+ /// <param name="libraryManager">An instance of <see cref="ILibraryManager"/>.</param>
+ /// <param name="providerManager">An instance of <see cref="IProviderManager"/>.</param>
+ /// <param name="logger">An instance of <see cref="ILogger{ImageFetcherPostScanTask}"/>.</param>
+ public ImageFetcherPostScanTask(
+ ILibraryManager libraryManager,
+ IProviderManager providerManager,
+ ILogger<ImageFetcherPostScanTask> logger)
+ {
+ _libraryManager = libraryManager;
+ _providerManager = providerManager;
+ _logger = logger;
+ _queuedItems = new ConcurrentDictionary<Guid, (BaseItem item, ItemUpdateType updateReason)>();
+ _imageFetcherLock = new SemaphoreSlim(1, 1);
+ _libraryManager.ItemAdded += OnLibraryManagerItemAddedOrUpdated;
+ _libraryManager.ItemUpdated += OnLibraryManagerItemAddedOrUpdated;
+ _providerManager.RefreshCompleted += OnProviderManagerRefreshCompleted;
+ }
+
+ /// <inheritdoc />
+ public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ // Sometimes a library scan will cause this to run twice if there's an item refresh going on.
+ await _imageFetcherLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ var now = DateTime.UtcNow;
+ var itemGuids = _queuedItems.Keys.ToList();
+
+ for (var i = 0; i < itemGuids.Count; i++)
+ {
+ if (!_queuedItems.TryGetValue(itemGuids[i], out var queuedItem))
+ {
+ continue;
+ }
+
+ var itemId = queuedItem.item.Id.ToString("N", CultureInfo.InvariantCulture);
+ var itemType = queuedItem.item.GetType();
+ _logger.LogDebug(
+ "Updating remote images for item {ItemId} with media type {ItemMediaType}",
+ itemId,
+ itemType);
+ try
+ {
+ await _libraryManager.UpdateImagesAsync(queuedItem.item, queuedItem.updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to fetch images for {Type} item with id {ItemId}", itemType, itemId);
+ }
+
+ _queuedItems.TryRemove(queuedItem.item.Id, out _);
+ }
+
+ if (itemGuids.Count > 0)
+ {
+ _logger.LogInformation(
+ "Finished updating/pre-fetching {NumberOfImages} images. Elapsed time: {TimeElapsed}s.",
+ itemGuids.Count.ToString(CultureInfo.InvariantCulture),
+ (DateTime.UtcNow - now).TotalSeconds.ToString(CultureInfo.InvariantCulture));
+ }
+ else
+ {
+ _logger.LogDebug("No images were updated.");
+ }
+ }
+ finally
+ {
+ _imageFetcherLock.Release();
+ }
+ }
+
+ private void OnLibraryManagerItemAddedOrUpdated(object sender, ItemChangeEventArgs itemChangeEventArgs)
+ {
+ if (!_queuedItems.ContainsKey(itemChangeEventArgs.Item.Id) && itemChangeEventArgs.Item.ImageInfos.Length > 0)
+ {
+ _queuedItems.AddOrUpdate(
+ itemChangeEventArgs.Item.Id,
+ (itemChangeEventArgs.Item, itemChangeEventArgs.UpdateReason),
+ (key, existingValue) => existingValue);
+ }
+ }
+
+ private void OnProviderManagerRefreshCompleted(object sender, GenericEventArgs<BaseItem> e)
+ {
+ if (!_queuedItems.ContainsKey(e.Argument.Id) && e.Argument.ImageInfos.Length > 0)
+ {
+ _queuedItems.AddOrUpdate(
+ e.Argument.Id,
+ (e.Argument, ItemUpdateType.None),
+ (key, existingValue) => existingValue);
+ }
+
+ // The RefreshCompleted event is a bit awkward in that it seems to _only_ be fired on
+ // the item that was refreshed regardless of children refreshes. So we take it as a signal
+ // that the refresh is entirely completed.
+ Run(null, CancellationToken.None).GetAwaiter().GetResult();
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index d83873441..5b926b0f4 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -858,7 +858,21 @@ namespace Emby.Server.Implementations.Library
/// <returns>Task{Person}.</returns>
public Person GetPerson(string name)
{
- return CreateItemByName<Person>(Person.GetPath, name, new DtoOptions(true));
+ var path = Person.GetPath(name);
+ var id = GetItemByNameId<Person>(path);
+ if (!(GetItemById(id) is Person item))
+ {
+ item = new Person
+ {
+ Name = name,
+ Id = id,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ Path = path
+ };
+ }
+
+ return item;
}
/// <summary>
@@ -1503,7 +1517,7 @@ namespace Emby.Server.Implementations.Library
{
if (query.AncestorIds.Length == 0 &&
query.ParentId.Equals(Guid.Empty) &&
- query.ChannelIds.Length == 0 &&
+ query.ChannelIds.Count == 0 &&
query.TopParentIds.Length == 0 &&
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
@@ -1941,19 +1955,9 @@ namespace Emby.Server.Implementations.Library
}
/// <inheritdoc />
- public async Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
+ public Task UpdateItemsAsync(IReadOnlyList<BaseItem> items, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
{
- foreach (var item in items)
- {
- if (item.IsFileProtocol)
- {
- ProviderManager.SaveMetadata(item, updateReason);
- }
-
- item.DateLastSaved = DateTime.UtcNow;
-
- await UpdateImagesAsync(item, updateReason >= ItemUpdateType.ImageUpdate).ConfigureAwait(false);
- }
+ RunMetadataSavers(items, updateReason);
_itemRepository.SaveItems(items, cancellationToken);
@@ -1984,12 +1988,27 @@ namespace Emby.Server.Implementations.Library
}
}
}
+
+ return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
=> UpdateItemsAsync(new[] { item }, parent, updateReason, cancellationToken);
+ public void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason)
+ {
+ foreach (var item in items)
+ {
+ if (item.IsFileProtocol)
+ {
+ ProviderManager.SaveMetadata(item, updateReason);
+ }
+
+ item.DateLastSaved = DateTime.UtcNow;
+ }
+ }
+
/// <summary>
/// Reports the item removed.
/// </summary>
@@ -2443,9 +2462,19 @@ namespace Emby.Server.Implementations.Library
public BaseItem GetParentItem(string parentId, Guid? userId)
{
- if (!string.IsNullOrEmpty(parentId))
+ if (string.IsNullOrEmpty(parentId))
+ {
+ return GetParentItem((Guid?)null, userId);
+ }
+
+ return GetParentItem(new Guid(parentId), userId);
+ }
+
+ public BaseItem GetParentItem(Guid? parentId, Guid? userId)
+ {
+ if (parentId.HasValue)
{
- return GetItemById(new Guid(parentId));
+ return GetItemById(parentId.Value);
}
if (userId.HasValue && userId != Guid.Empty)
diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
index 179e0ed98..28fa06239 100644
--- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs
+++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
@@ -101,7 +101,7 @@ namespace Emby.Server.Implementations.Library
private static IEnumerable<MediaStream> GetSortedStreams(IEnumerable<MediaStream> streams, MediaStreamType type, string[] languagePreferences)
{
- // Give some preferance to external text subs for better performance
+ // Give some preference to external text subs for better performance
return streams.Where(i => i.Type == type)
.OrderBy(i =>
{
diff --git a/Emby.Server.Implementations/Library/SearchEngine.cs b/Emby.Server.Implementations/Library/SearchEngine.cs
index c850e3a08..1d9529dff 100644
--- a/Emby.Server.Implementations/Library/SearchEngine.cs
+++ b/Emby.Server.Implementations/Library/SearchEngine.cs
@@ -156,8 +156,8 @@ namespace Emby.Server.Implementations.Library
ExcludeItemTypes = excludeItemTypes.ToArray(),
IncludeItemTypes = includeItemTypes.ToArray(),
Limit = query.Limit,
- IncludeItemsByName = string.IsNullOrEmpty(query.ParentId),
- ParentId = string.IsNullOrEmpty(query.ParentId) ? Guid.Empty : new Guid(query.ParentId),
+ IncludeItemsByName = !query.ParentId.HasValue,
+ ParentId = query.ParentId ?? Guid.Empty,
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
Recursive = true,
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs
index 44560d1e2..341194f23 100644
--- a/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/DirectRecorder.cs
@@ -77,11 +77,12 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
_logger.LogInformation("Copying recording stream to file {0}", targetFile);
// The media source if infinite so we need to handle stopping ourselves
- var durationToken = new CancellationTokenSource(duration);
- cancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token).Token;
+ using var durationToken = new CancellationTokenSource(duration);
+ using var linkedCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, durationToken.Token);
+ cancellationToken = linkedCancellationToken.Token;
await _streamHelper.CopyUntilCancelled(
- await response.Content.ReadAsStreamAsync().ConfigureAwait(false),
+ await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false),
output,
IODefaults.CopyToBufferSize,
cancellationToken).ConfigureAwait(false);
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
index fcc2d1eeb..0dc045ee6 100644
--- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs
@@ -1635,7 +1635,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
{
if (mediaSource.RequiresLooping || !(mediaSource.Container ?? string.Empty).EndsWith("ts", StringComparison.OrdinalIgnoreCase) || (mediaSource.Protocol != MediaProtocol.File && mediaSource.Protocol != MediaProtocol.Http))
{
- return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer);
+ return new EncodedRecorder(_logger, _mediaEncoder, _config.ApplicationPaths, _jsonSerializer, _config);
}
return new DirectRecorder(_logger, _httpClientFactory, _streamHelper);
diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs
index 3e5457dbd..e6ee9819e 100644
--- a/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs
+++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EncodedRecorder.cs
@@ -8,7 +8,9 @@ using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
@@ -25,6 +27,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
private readonly IServerApplicationPaths _appPaths;
private readonly IJsonSerializer _json;
private readonly TaskCompletionSource<bool> _taskCompletionSource = new TaskCompletionSource<bool>();
+ private readonly IServerConfigurationManager _serverConfigurationManager;
private bool _hasExited;
private Stream _logFileStream;
@@ -35,12 +38,14 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
ILogger logger,
IMediaEncoder mediaEncoder,
IServerApplicationPaths appPaths,
- IJsonSerializer json)
+ IJsonSerializer json,
+ IServerConfigurationManager serverConfigurationManager)
{
_logger = logger;
_mediaEncoder = mediaEncoder;
_appPaths = appPaths;
_json = json;
+ _serverConfigurationManager = serverConfigurationManager;
}
private static bool CopySubtitles => false;
@@ -179,15 +184,17 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV
var outputParam = string.Empty;
+ var threads = EncodingHelper.GetNumberOfThreads(null, _serverConfigurationManager.GetEncodingOptions(), null);
var commandLineArgs = string.Format(
CultureInfo.InvariantCulture,
- "-i \"{0}\" {2} -map_metadata -1 -threads 0 {3}{4}{5} -y \"{1}\"",
+ "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"",
inputTempFile,
targetFile,
videoArgs,
GetAudioArgs(mediaSource),
subtitleArgs,
- outputParam);
+ outputParam,
+ threads);
return inputModifier + " " + commandLineArgs;
}
diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
index 43128c60d..1084ddf74 100644
--- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
+++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs
@@ -4,7 +4,6 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
-using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
@@ -19,7 +18,6 @@ using MediaBrowser.Model.Cryptography;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.LiveTv;
-using MediaBrowser.Model.Net;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
@@ -36,6 +34,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings
private readonly IApplicationHost _appHost;
private readonly ICryptoProvider _cryptoProvider;
+ private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>();
+ private DateTime _lastErrorResponse;
+
public SchedulesDirect(
ILogger<SchedulesDirect> logger,
IJsonSerializer jsonSerializer,
@@ -50,8 +51,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings
_cryptoProvider = cryptoProvider;
}
- private string UserAgent => _appHost.ApplicationUserAgent;
-
/// <inheritdoc />
public string Name => "Schedules Direct";
@@ -112,7 +111,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
options.Content = new StringContent(requestString, Encoding.UTF8, MediaTypeNames.Application.Json);
options.Headers.TryAddWithoutValidation("token", token);
using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
- await using var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var dailySchedules = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Day>>(responseStream).ConfigureAwait(false);
_logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId);
@@ -123,7 +122,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
programRequestOptions.Content = new StringContent("[\"" + string.Join("\", \"", programsID) + "\"]", Encoding.UTF8, MediaTypeNames.Application.Json);
using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false);
- await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var programDetails = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ProgramDetails>>(innerResponseStream).ConfigureAwait(false);
var programDict = programDetails.ToDictionary(p => p.programID, y => y);
@@ -261,7 +260,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
Id = newID,
StartDate = startAt,
EndDate = endAt,
- Name = details.titles[0].title120 ?? "Unkown",
+ Name = details.titles[0].title120 ?? "Unknown",
OfficialRating = null,
CommunityRating = null,
EpisodeTitle = episodeTitle,
@@ -307,7 +306,8 @@ namespace Emby.Server.Implementations.LiveTv.Listings
if (details.contentRating != null && details.contentRating.Count > 0)
{
- info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-").Replace("--", "-");
+ info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-", StringComparison.Ordinal)
+ .Replace("--", "-", StringComparison.Ordinal);
var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" };
if (invalid.Contains(info.OfficialRating, StringComparer.OrdinalIgnoreCase))
@@ -450,7 +450,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms(
ListingsProviderInfo info,
- List<string> programIds,
+ IReadOnlyList<string> programIds,
CancellationToken cancellationToken)
{
if (programIds.Count == 0)
@@ -458,31 +458,28 @@ namespace Emby.Server.Implementations.LiveTv.Listings
return new List<ScheduleDirect.ShowImages>();
}
- var imageIdString = "[";
-
- foreach (var i in programIds)
+ StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13));
+ foreach (ReadOnlySpan<char> i in programIds)
{
- var imageId = i.Substring(0, 10);
-
- if (!imageIdString.Contains(imageId, StringComparison.Ordinal))
- {
- imageIdString += "\"" + imageId + "\",";
- }
+ str.Append('"')
+ .Append(i.Slice(0, 10))
+ .Append("\",");
}
- imageIdString = imageIdString.TrimEnd(',') + "]";
+ // Remove last ,
+ str.Length--;
+ str.Append(']');
using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs")
{
- Content = new StringContent(imageIdString, Encoding.UTF8, MediaTypeNames.Application.Json)
+ Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json)
};
try
{
using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false);
- await using var response = await innerResponse2.Content.ReadAsStreamAsync().ConfigureAwait(false);
- return await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ShowImages>>(
- response).ConfigureAwait(false);
+ await using var response = await innerResponse2.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ return await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ShowImages>>(response).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -509,7 +506,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
try
{
using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false);
- await using var response = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var response = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var root = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Headends>>(response).ConfigureAwait(false);
@@ -540,8 +537,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings
return lineups;
}
- private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>();
- private DateTime _lastErrorResponse;
private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
{
var username = info.Username;
@@ -564,8 +559,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
return null;
}
- NameValuePair savedToken;
- if (!_tokens.TryGetValue(username, out savedToken))
+ if (!_tokens.TryGetValue(username, out NameValuePair savedToken))
{
savedToken = new NameValuePair();
_tokens.TryAdd(username, savedToken);
@@ -647,13 +641,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings
{
using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>());
- string hashedPassword = Hex.Encode(hashedPasswordBytes);
+ // TODO: remove ToLower when Convert.ToHexString supports lowercase
+ // Schedules Direct requires the hex to be lowercase
+ string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json);
using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(stream).ConfigureAwait(false);
- if (root.message == "OK")
+ if (string.Equals(root.message, "OK", StringComparison.Ordinal))
{
_logger.LogInformation("Authenticated with Schedules Direct token: " + root.token);
return root.token;
@@ -705,7 +701,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
try
{
using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false);
- await using var stream = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var response = httpResponse.Content;
var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Lineups>(stream).ConfigureAwait(false);
@@ -777,24 +773,28 @@ namespace Emby.Server.Implementations.LiveTv.Listings
using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
options.Headers.TryAddWithoutValidation("token", token);
- var list = new List<ChannelInfo>();
-
using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false);
- await using var stream = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Channel>(stream).ConfigureAwait(false);
_logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.map.Count);
_logger.LogInformation("Mapping Stations to Channel");
- var allStations = root.stations ?? Enumerable.Empty<ScheduleDirect.Station>();
+ var allStations = root.stations ?? new List<ScheduleDirect.Station>();
- foreach (ScheduleDirect.Map map in root.map)
+ var map = root.map;
+ int len = map.Count;
+ var array = new List<ChannelInfo>(len);
+ for (int i = 0; i < len; i++)
{
- var channelNumber = GetChannelNumber(map);
+ var channelNumber = GetChannelNumber(map[i]);
- var station = allStations.FirstOrDefault(item => string.Equals(item.stationID, map.stationID, StringComparison.OrdinalIgnoreCase));
+ var station = allStations.Find(item => string.Equals(item.stationID, map[i].stationID, StringComparison.OrdinalIgnoreCase));
if (station == null)
{
- station = new ScheduleDirect.Station { stationID = map.stationID };
+ station = new ScheduleDirect.Station
+ {
+ stationID = map[i].stationID
+ };
}
var channelInfo = new ChannelInfo
@@ -810,32 +810,10 @@ namespace Emby.Server.Implementations.LiveTv.Listings
channelInfo.ImageUrl = station.logo.URL;
}
- list.Add(channelInfo);
- }
-
- return list;
- }
-
- private ScheduleDirect.Station GetStation(List<ScheduleDirect.Station> allStations, string channelNumber, string channelName)
- {
- if (!string.IsNullOrWhiteSpace(channelName))
- {
- channelName = NormalizeName(channelName);
-
- var result = allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.callsign ?? string.Empty), channelName, StringComparison.OrdinalIgnoreCase));
-
- if (result != null)
- {
- return result;
- }
- }
-
- if (!string.IsNullOrWhiteSpace(channelNumber))
- {
- return allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.stationID ?? string.Empty), channelNumber, StringComparison.OrdinalIgnoreCase));
+ array[i] = channelInfo;
}
- return null;
+ return array;
}
private static string NormalizeName(string value)
@@ -1044,7 +1022,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings
}
}
- //
public class Title
{
public string title120 { get; set; }
diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs
index 2d6f453bd..76c875737 100644
--- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs
+++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs
@@ -79,7 +79,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings
Directory.CreateDirectory(Path.GetDirectoryName(cacheFile));
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(path, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (var fileStream = new FileStream(cacheFile, FileMode.CreateNew))
{
await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
diff --git a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs
index 8a0c0043a..3a738fd5d 100644
--- a/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs
+++ b/Emby.Server.Implementations/LiveTv/LiveTvMediaSourceProvider.cs
@@ -76,7 +76,6 @@ namespace Emby.Server.Implementations.LiveTv
}
var list = sources.ToList();
- var serverUrl = await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
foreach (var source in list)
{
@@ -103,7 +102,7 @@ namespace Emby.Server.Implementations.LiveTv
// Dummy this up so that direct play checks can still run
if (string.IsNullOrEmpty(source.Path) && source.Protocol == MediaProtocol.Http)
{
- source.Path = serverUrl;
+ source.Path = _appHost.GetSmartApiUrl(string.Empty);
}
}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
index 9fdbad63c..b6444b172 100644
--- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs
@@ -72,7 +72,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, cancellationToken: cancellationToken)
.ConfigureAwait(false) ?? new List<Channels>();
@@ -129,7 +129,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/discover.json", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, cancellationToken: cancellationToken)
.ConfigureAwait(false);
@@ -175,7 +175,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var sr = new StreamReader(stream, System.Text.Encoding.UTF8);
var tuners = new List<LiveTvTunerInfo>();
while (!sr.EndOfStream)
@@ -237,8 +237,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
if (!inside)
{
- buffer[bufferIndex] = let;
- bufferIndex++;
+ buffer[bufferIndex++] = let;
}
}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
index d4a88e299..cdc8c6870 100644
--- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs
@@ -111,11 +111,11 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken)
{
- using (var client = new TcpClient(new IPEndPoint(remoteIp, HdHomeRunPort)))
- using (var stream = client.GetStream())
- {
- return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false);
- }
+ using var client = new TcpClient();
+ client.Connect(remoteIp, HdHomeRunPort);
+
+ using var stream = client.GetStream();
+ return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false);
}
private static async Task<bool> CheckTunerAvailability(NetworkStream stream, int tuner, CancellationToken cancellationToken)
@@ -142,7 +142,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
{
_remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort);
- _tcpClient = new TcpClient(_remoteEndPoint);
+ _tcpClient = new TcpClient();
+ _tcpClient.Connect(_remoteEndPoint);
if (!_lockkey.HasValue)
{
@@ -221,30 +222,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
return;
}
- using (var tcpClient = new TcpClient(_remoteEndPoint))
- using (var stream = tcpClient.GetStream())
+ using var tcpClient = new TcpClient();
+ tcpClient.Connect(_remoteEndPoint);
+
+ using var stream = tcpClient.GetStream();
+ var commandList = commands.GetCommands();
+ byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
+ try
{
- var commandList = commands.GetCommands();
- byte[] buffer = ArrayPool<byte>.Shared.Rent(8192);
- try
+ foreach (var command in commandList)
{
- foreach (var command in commandList)
- {
- var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey);
- await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
- int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
+ var channelMsg = CreateSetMessage(_activeTuner, command.Item1, command.Item2, _lockkey);
+ await stream.WriteAsync(channelMsg, 0, channelMsg.Length, cancellationToken).ConfigureAwait(false);
+ int receivedBytes = await stream.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
- // parse response to make sure it worked
- if (!ParseReturnMessage(buffer, receivedBytes, out _))
- {
- return;
- }
+ // parse response to make sure it worked
+ if (!ParseReturnMessage(buffer, receivedBytes, out _))
+ {
+ return;
}
}
- finally
- {
- ArrayPool<byte>.Shared.Return(buffer);
- }
+ }
+ finally
+ {
+ ArrayPool<byte>.Shared.Return(buffer);
}
}
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
index 858c10030..cf653f87d 100644
--- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunUdpStream.cs
@@ -3,7 +3,9 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Net;
+using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
@@ -50,6 +52,26 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
EnableStreamSharing = true;
}
+ /// <summary>
+ /// Returns an unused UDP port number in the range specified.
+ /// Temporarily placed here until future network PR merged.
+ /// </summary>
+ /// <param name="range">Upper and Lower boundary of ports to select.</param>
+ /// <returns>System.Int32.</returns>
+ private static int GetUdpPortFromRange((int Min, int Max) range)
+ {
+ var properties = IPGlobalProperties.GetIPGlobalProperties();
+
+ // Get active udp listeners.
+ var udpListenerPorts = properties.GetActiveUdpListeners()
+ .Where(n => n.Port >= range.Min && n.Port <= range.Max)
+ .Select(n => n.Port);
+
+ return Enumerable
+ .Range(range.Min, range.Max)
+ .FirstOrDefault(i => !udpListenerPorts.Contains(i));
+ }
+
public override async Task Open(CancellationToken openCancellationToken)
{
LiveStreamCancellationTokenSource.Token.ThrowIfCancellationRequested();
@@ -57,7 +79,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
var mediaSource = OriginalMediaSource;
var uri = new Uri(mediaSource.Path);
- var localPort = _networkManager.GetRandomUnusedUdpPort();
+ // Temporary code to reduce PR size. This will be updated by a future network pr.
+ var localPort = GetUdpPortFromRange((49152, 65535));
Directory.CreateDirectory(Path.GetDirectoryName(TempFilePath));
@@ -70,7 +93,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
try
{
await tcpClient.ConnectAsync(remoteAddress, HdHomerunManager.HdHomeRunPort).ConfigureAwait(false);
- localAddress = ((IPEndPoint)tcpClient.Client.RemoteEndPoint).Address;
+ localAddress = ((IPEndPoint)tcpClient.Client.LocalEndPoint).Address;
tcpClient.Close();
}
catch (Exception ex)
@@ -80,6 +103,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
}
}
+ if (localAddress.IsIPv4MappedToIPv6) {
+ localAddress = localAddress.MapToIPv4();
+ }
+
var udpClient = new UdpClient(localPort, AddressFamily.InterNetwork);
var hdHomerunManager = new HdHomerunManager();
@@ -110,12 +137,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
var taskCompletionSource = new TaskCompletionSource<bool>();
- await StartStreaming(
+ _ = StartStreaming(
udpClient,
hdHomerunManager,
remoteAddress,
taskCompletionSource,
- LiveStreamCancellationTokenSource.Token).ConfigureAwait(false);
+ LiveStreamCancellationTokenSource.Token);
// OpenedMediaSource.Protocol = MediaProtocol.File;
// OpenedMediaSource.Path = tempFile;
@@ -136,33 +163,30 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun
return TempFilePath;
}
- private Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
+ private async Task StartStreaming(UdpClient udpClient, HdHomerunManager hdHomerunManager, IPAddress remoteAddress, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
{
- return Task.Run(async () =>
+ using (udpClient)
+ using (hdHomerunManager)
{
- using (udpClient)
- using (hdHomerunManager)
+ try
{
- try
- {
- await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException ex)
- {
- Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress);
- openTaskCompletionSource.TrySetException(ex);
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Error opening live stream:");
- openTaskCompletionSource.TrySetException(ex);
- }
-
- EnableStreamSharing = false;
+ await CopyTo(udpClient, TempFilePath, openTaskCompletionSource, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException ex)
+ {
+ Logger.LogInformation("HDHR UDP stream cancelled or timed out from {0}", remoteAddress);
+ openTaskCompletionSource.TrySetException(ex);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error opening live stream:");
+ openTaskCompletionSource.TrySetException(ex);
}
- await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
- });
+ EnableStreamSharing = false;
+ }
+
+ await DeleteTempFiles(new List<string> { TempFilePath }).ConfigureAwait(false);
}
private async Task CopyTo(UdpClient udpClient, string file, TaskCompletionSource<bool> openTaskCompletionSource, CancellationToken cancellationToken)
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
index 7c13d45e9..c82b67b41 100644
--- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs
@@ -63,7 +63,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
.SendAsync(requestMessage, cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
- return await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
return File.OpenRead(info.Url);
@@ -95,7 +95,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
extInf = line.Substring(ExtInfPrefix.Length).Trim();
_logger.LogInformation("Found m3u channel: {0}", extInf);
}
- else if (!string.IsNullOrWhiteSpace(extInf) && !line.StartsWith("#", StringComparison.OrdinalIgnoreCase))
+ else if (!string.IsNullOrWhiteSpace(extInf) && !line.StartsWith('#'))
{
var channel = GetChannelnfo(extInf, tunerHostId, line);
if (string.IsNullOrWhiteSpace(channel.Id))
@@ -197,7 +197,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
if (string.IsNullOrWhiteSpace(numberString))
{
// Using this as a fallback now as this leads to Problems with channels like "5 USA"
- // where 5 isnt ment to be the channel number
+ // where 5 isn't ment to be the channel number
// Check for channel number with the format from SatIp
// #EXTINF:0,84. VOX Schweiz
// #EXTINF:0,84.0 - VOX Schweiz
diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs
index 2e1b89509..f7507e6ba 100644
--- a/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs
+++ b/Emby.Server.Implementations/LiveTv/TunerHosts/SharedHttpStream.cs
@@ -63,7 +63,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
var extension = "ts";
var requiresRemux = false;
- var contentType = response.Content.Headers.ContentType.ToString();
+ var contentType = response.Content.Headers.ContentType?.ToString() ?? string.Empty;
if (contentType.IndexOf("matroska", StringComparison.OrdinalIgnoreCase) != -1)
{
requiresRemux = true;
@@ -135,7 +135,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts
{
Logger.LogInformation("Beginning {0} stream to {1}", GetType().Name, TempFilePath);
using var message = response;
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var fileStream = new FileStream(TempFilePath, FileMode.Create, FileAccess.Write, FileShare.Read);
await StreamHelper.CopyToAsync(
stream,
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index b29ad94ef..4ee4eb989 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -113,5 +113,10 @@
"TaskCleanTranscodeDescription": "Fjern transcode filer som er mere end en dag gammel.",
"TaskCleanTranscode": "Rengør Transcode Mappen",
"TaskRefreshPeople": "Genopfrisk Personer",
- "TaskRefreshPeopleDescription": "Opdatere metadata for skuespillere og instruktører i dit bibliotek."
+ "TaskRefreshPeopleDescription": "Opdatere metadata for skuespillere og instruktører i dit bibliotek.",
+ "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigureret alder.",
+ "TaskCleanActivityLog": "Ryd Aktivitetslog",
+ "Undefined": "Udefineret",
+ "Forced": "Tvunget",
+ "Default": "Standard"
}
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index c45cc11cb..23d45b473 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -113,5 +113,10 @@
"TaskCleanTranscode": "Καθαρισμός Kαταλόγου Διακωδικοποιητή",
"TaskUpdatePluginsDescription": "Κατεβάζει και εγκαθιστά ενημερώσεις για τις προσθήκες που έχουν ρυθμιστεί για αυτόματη ενημέρωση.",
"TaskUpdatePlugins": "Ενημέρωση Προσθηκών",
- "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας."
+ "TaskRefreshPeopleDescription": "Ενημερώνει μεταδεδομένα για ηθοποιούς και σκηνοθέτες στην βιβλιοθήκη των πολυμέσων σας.",
+ "TaskCleanActivityLogDescription": "Διαγράφει καταχωρήσεις απο το αρχείο καταγραφής παλαιότερες από την επιλεγμένη ηλικία.",
+ "TaskCleanActivityLog": "Καθαρό Αρχείο Καταγραφής Δραστηριοτήτων",
+ "Undefined": "Απροσδιόριστο",
+ "Forced": "Εξαναγκασμένο",
+ "Default": "Προεπιλογή"
}
diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json
index ab54c0ea6..05181116d 100644
--- a/Emby.Server.Implementations/Localization/Core/es-MX.json
+++ b/Emby.Server.Implementations/Localization/Core/es-MX.json
@@ -113,5 +113,9 @@
"TasksChannelsCategory": "Canales de Internet",
"TasksApplicationCategory": "Aplicación",
"TasksLibraryCategory": "Biblioteca",
- "TasksMaintenanceCategory": "Mantenimiento"
+ "TasksMaintenanceCategory": "Mantenimiento",
+ "TaskCleanActivityLogDescription": "Elimina entradas del registro de actividad que sean más antiguas al periodo establecido.",
+ "TaskCleanActivityLog": "Limpiar registro de actividades",
+ "Undefined": "Sin definir",
+ "Forced": "Forzado"
}
diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json
index fe674cf36..16fde325f 100644
--- a/Emby.Server.Implementations/Localization/Core/es.json
+++ b/Emby.Server.Implementations/Localization/Core/es.json
@@ -70,7 +70,7 @@
"ScheduledTaskFailedWithName": "{0} falló",
"ScheduledTaskStartedWithName": "{0} iniciada",
"ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado",
- "Shows": "Mostrar",
+ "Shows": "Series de Televisión",
"Songs": "Canciones",
"StartupEmbyServerIsLoading": "Jellyfin Server se está cargando. Vuelve a intentarlo en breve.",
"SubtitleDownloadFailureForItem": "Error al descargar subtítulos para {0}",
diff --git a/Emby.Server.Implementations/Localization/Core/fa.json b/Emby.Server.Implementations/Localization/Core/fa.json
index 1986decf0..7eb8e36e7 100644
--- a/Emby.Server.Implementations/Localization/Core/fa.json
+++ b/Emby.Server.Implementations/Localization/Core/fa.json
@@ -113,5 +113,7 @@
"TasksChannelsCategory": "کانال‌های داخلی",
"TasksApplicationCategory": "برنامه",
"TasksLibraryCategory": "کتابخانه",
- "TasksMaintenanceCategory": "تعمیر"
+ "TasksMaintenanceCategory": "تعمیر",
+ "Forced": "اجباری",
+ "Default": "پیشفرض"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json
index 8e219a9ce..954759b5c 100644
--- a/Emby.Server.Implementations/Localization/Core/fi.json
+++ b/Emby.Server.Implementations/Localization/Core/fi.json
@@ -112,5 +112,10 @@
"TaskCleanCache": "Tyhjennä välimuisti-hakemisto",
"TasksChannelsCategory": "Internet kanavat",
"TasksApplicationCategory": "Sovellus",
- "TasksLibraryCategory": "Kirjasto"
+ "TasksLibraryCategory": "Kirjasto",
+ "Forced": "Pakotettu",
+ "Default": "Oletus",
+ "TaskCleanActivityLogDescription": "Poistaa määritettyä vanhemmat tapahtumat aktiviteettilokista.",
+ "TaskCleanActivityLog": "Tyhjennä aktiviteettiloki",
+ "Undefined": "Määrittelemätön"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json
index 3d7592e3c..0c19a0152 100644
--- a/Emby.Server.Implementations/Localization/Core/fr-CA.json
+++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json
@@ -15,8 +15,8 @@
"Favorites": "Favoris",
"Folders": "Dossiers",
"Genres": "Genres",
- "HeaderAlbumArtists": "Artistes de l'album",
- "HeaderContinueWatching": "Continuer à regarder",
+ "HeaderAlbumArtists": "Artistes",
+ "HeaderContinueWatching": "Reprendre le visionnement",
"HeaderFavoriteAlbums": "Albums favoris",
"HeaderFavoriteArtists": "Artistes favoris",
"HeaderFavoriteEpisodes": "Épisodes favoris",
@@ -113,5 +113,6 @@
"TaskCleanCache": "Nettoyer le répertoire des fichiers temporaires",
"TasksApplicationCategory": "Application",
"TaskCleanCacheDescription": "Supprime les fichiers temporaires qui ne sont plus nécessaire pour le système.",
- "TasksChannelsCategory": "Canaux Internet"
+ "TasksChannelsCategory": "Canaux Internet",
+ "Default": "Par défaut"
}
diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json
index 3d5d69f36..1e195378f 100644
--- a/Emby.Server.Implementations/Localization/Core/fr.json
+++ b/Emby.Server.Implementations/Localization/Core/fr.json
@@ -93,8 +93,8 @@
"ValueSpecialEpisodeName": "Spécial - {0}",
"VersionNumber": "Version {0}",
"TasksChannelsCategory": "Chaines en ligne",
- "TaskDownloadMissingSubtitlesDescription": "Cherche les sous-titres manquant sur internet en se basant sur la configuration des métadonnées.",
- "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquant",
+ "TaskDownloadMissingSubtitlesDescription": "Recherche les sous-titres manquants sur internet en se basant sur la configuration des métadonnées.",
+ "TaskDownloadMissingSubtitles": "Télécharger les sous-titres manquants",
"TaskRefreshChannelsDescription": "Rafraîchit les informations des chaines en ligne.",
"TaskRefreshChannels": "Rafraîchir les chaines",
"TaskCleanTranscodeDescription": "Supprime les fichiers transcodés de plus d'un jour.",
diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json
index f906d6e11..981e8a06e 100644
--- a/Emby.Server.Implementations/Localization/Core/he.json
+++ b/Emby.Server.Implementations/Localization/Core/he.json
@@ -113,5 +113,10 @@
"TaskRefreshChannels": "רענן ערוץ",
"TaskCleanTranscodeDescription": "מחק קבצי transcode שנוצרו מלפני יותר מיום.",
"TaskCleanTranscode": "נקה תקיית Transcode",
- "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי."
+ "TaskUpdatePluginsDescription": "הורד והתקן עדכונים עבור תוספים שמוגדרים לעדכון אוטומטי.",
+ "TaskCleanActivityLogDescription": "מחק רשומת פעילות הישנה יותר מהגיל המוגדר.",
+ "TaskCleanActivityLog": "נקה רשומת פעילות",
+ "Undefined": "לא מוגדר",
+ "Forced": "כפוי",
+ "Default": "ברירת מחדל"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
index 804dabe57..e5707e78c 100644
--- a/Emby.Server.Implementations/Localization/Core/hu.json
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -115,5 +115,8 @@
"TaskRefreshChannels": "Csatornák frissítése",
"TaskCleanTranscodeDescription": "Törli az egy napnál régebbi átkódolási fájlokat.",
"TaskCleanActivityLogDescription": "A beállítottnál korábbi bejegyzések törlése a tevékenységnaplóból.",
- "TaskCleanActivityLog": "Tevékenységnapló törlése"
+ "TaskCleanActivityLog": "Tevékenységnapló törlése",
+ "Undefined": "Meghatározatlan",
+ "Forced": "Kényszerített",
+ "Default": "Alapértelmezett"
}
diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json
index ef3ed2580..105ef7be9 100644
--- a/Emby.Server.Implementations/Localization/Core/id.json
+++ b/Emby.Server.Implementations/Localization/Core/id.json
@@ -112,5 +112,10 @@
"TaskRefreshPeople": "Muat ulang Orang",
"TaskCleanLogsDescription": "Menghapus file log yang lebih dari {0} hari.",
"TaskCleanLogs": "Bersihkan Log Direktori",
- "TaskRefreshLibrary": "Pindai Pustaka Media"
+ "TaskRefreshLibrary": "Pindai Pustaka Media",
+ "TaskCleanActivityLogDescription": "Menghapus log aktivitas yang lebih tua dari umur yang dikonfigurasi.",
+ "TaskCleanActivityLog": "Bersihkan Log Aktivitas",
+ "Undefined": "Tidak terdefinisi",
+ "Forced": "Dipaksa",
+ "Default": "Bawaan"
}
diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json
index 9e37ddc27..110f8043d 100644
--- a/Emby.Server.Implementations/Localization/Core/it.json
+++ b/Emby.Server.Implementations/Localization/Core/it.json
@@ -115,5 +115,8 @@
"TasksLibraryCategory": "Libreria",
"TasksMaintenanceCategory": "Manutenzione",
"TaskCleanActivityLog": "Attività di Registro Completate",
- "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata."
+ "TaskCleanActivityLogDescription": "Elimina gli inserimenti nel registro delle attività più vecchie dell’età configurata.",
+ "Undefined": "Non Definito",
+ "Forced": "Forzato",
+ "Default": "Predefinito"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json
index 5e3acfbe9..a46bdc3de 100644
--- a/Emby.Server.Implementations/Localization/Core/lv.json
+++ b/Emby.Server.Implementations/Localization/Core/lv.json
@@ -15,9 +15,9 @@
"NotificationOptionUserLockedOut": "Lietotājs bloķēts",
"LabelRunningTimeValue": "Garums: {0}",
"Inherit": "Mantot",
- "AppDeviceValues": "Lietotne:{0}, Ierīce:{1}",
+ "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}",
"VersionNumber": "Versija {0}",
- "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots tavai multvides bibliotēkai",
+ "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai",
"UserStoppedPlayingItemWithValues": "{0} ir beidzis atskaņot {1} uz {2}",
"UserStartedPlayingItemWithValues": "{0} atskaņo {1} uz {2}",
"UserPasswordChangedWithName": "Parole nomainīta lietotājam {0}",
@@ -95,7 +95,7 @@
"TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus",
"TasksApplicationCategory": "Lietotne",
"TasksLibraryCategory": "Bibliotēka",
- "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus pēc metadatu uzstādījumiem.",
+ "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.",
"TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošus subtitrus",
"TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.",
"TaskRefreshChannels": "Atjaunot Kanālus",
@@ -103,14 +103,19 @@
"TaskCleanTranscode": "Iztīrīt Trans-kodēšanas Mapi",
"TaskUpdatePluginsDescription": "Lejupielādē un uzstāda atjauninājumus paplašinājumiem, kam ir uzstādīta automātiskā atjaunināšana.",
"TaskUpdatePlugins": "Atjaunot Paplašinājumus",
- "TaskRefreshPeopleDescription": "Atjauno metadatus priekš aktieriem un direktoriem tavā mediju bibliotēkā.",
+ "TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.",
"TaskRefreshPeople": "Atjaunot Cilvēkus",
"TaskCleanLogsDescription": "Nodzēš log datnes, kas ir vairāk par {0} dienām vecas.",
"TaskCleanLogs": "Iztīrīt Logdatņu Mapi",
- "TaskRefreshLibraryDescription": "Skenē tavas mediju bibliotēkas priekš jaunām datnēm un atjauno metadatus.",
- "TaskRefreshLibrary": "Skanēt Mediju Bibliotēku",
+ "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.",
"TaskCleanCache": "Iztīrīt Kešošanas Mapi",
"TasksChannelsCategory": "Interneta Kanāli",
- "TasksMaintenanceCategory": "Apkope"
+ "TasksMaintenanceCategory": "Apkope",
+ "Forced": "Piespiests",
+ "TaskCleanActivityLogDescription": "Nodzēš darbību žurnāla ierakstus, kuri ir vecāki par doto vecumu.",
+ "TaskCleanActivityLog": "Notīrīt Darbību Žurnālu",
+ "Undefined": "Nenoteikts",
+ "Default": "Noklusējums"
}
diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json
index e1e88cc9b..b6672a554 100644
--- a/Emby.Server.Implementations/Localization/Core/nl.json
+++ b/Emby.Server.Implementations/Localization/Core/nl.json
@@ -87,7 +87,7 @@
"UserOnlineFromDevice": "{0} heeft verbinding met {1}",
"UserPasswordChangedWithName": "Wachtwoord voor {0} is gewijzigd",
"UserPolicyUpdatedWithName": "Gebruikersbeleid gewijzigd voor {0}",
- "UserStartedPlayingItemWithValues": "{0} heeft afspelen van {1} gestart op {2}",
+ "UserStartedPlayingItemWithValues": "{0} speelt {1} af op {2}",
"UserStoppedPlayingItemWithValues": "{0} heeft afspelen van {1} gestopt op {2}",
"ValueHasBeenAddedToLibrary": "{0} is toegevoegd aan je mediabibliotheek",
"ValueSpecialEpisodeName": "Speciaal - {0}",
@@ -115,5 +115,8 @@
"TasksLibraryCategory": "Bibliotheek",
"TasksMaintenanceCategory": "Onderhoud",
"TaskCleanActivityLogDescription": "Verwijder activiteiten logs ouder dan de ingestelde tijd.",
- "TaskCleanActivityLog": "Leeg activiteiten logboek"
+ "TaskCleanActivityLog": "Leeg activiteiten logboek",
+ "Undefined": "Niet gedefinieerd",
+ "Forced": "Geforceerd",
+ "Default": "Standaard"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json
index 003e591b3..e3da96a85 100644
--- a/Emby.Server.Implementations/Localization/Core/pl.json
+++ b/Emby.Server.Implementations/Localization/Core/pl.json
@@ -113,5 +113,10 @@
"TasksChannelsCategory": "Kanały internetowe",
"TasksApplicationCategory": "Aplikacja",
"TasksLibraryCategory": "Biblioteka",
- "TasksMaintenanceCategory": "Konserwacja"
+ "TasksMaintenanceCategory": "Konserwacja",
+ "TaskCleanActivityLogDescription": "Usuwa wpisy dziennika aktywności starsze niż skonfigurowany wiek.",
+ "TaskCleanActivityLog": "Czyść dziennik aktywności",
+ "Undefined": "Nieustalony",
+ "Forced": "Wymuszony",
+ "Default": "Domyślne"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index 90a4941c5..8c41edf96 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -113,5 +113,10 @@
"TasksChannelsCategory": "Canais da Internet",
"TasksApplicationCategory": "Aplicação",
"TasksLibraryCategory": "Biblioteca",
- "TasksMaintenanceCategory": "Manutenção"
+ "TasksMaintenanceCategory": "Manutenção",
+ "TaskCleanActivityLogDescription": "Apaga as entradas do registo de atividade anteriores à data configurada.",
+ "TaskCleanActivityLog": "Limpar registo de atividade",
+ "Undefined": "Indefinido",
+ "Forced": "Forçado",
+ "Default": "Padrão"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json
index 2079940cd..f1a78b2d3 100644
--- a/Emby.Server.Implementations/Localization/Core/pt.json
+++ b/Emby.Server.Implementations/Localization/Core/pt.json
@@ -112,5 +112,10 @@
"TaskUpdatePluginsDescription": "Download e instala as atualizações para plug-ins configurados para atualização automática.",
"TaskRefreshPeopleDescription": "Atualiza os metadados para atores e diretores na tua biblioteca de media.",
"TaskRefreshPeople": "Atualizar pessoas",
- "TaskRefreshLibraryDescription": "Pesquisa a tua biblioteca de media por novos ficheiros e atualiza os metadados."
+ "TaskRefreshLibraryDescription": "Pesquisa a tua biblioteca de media por novos ficheiros e atualiza os metadados.",
+ "TaskCleanActivityLog": "Limpar registo de atividade",
+ "Undefined": "Indefinido",
+ "Forced": "Forçado",
+ "Default": "Predefinição",
+ "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado."
}
diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json
index 5e4022292..510aac11c 100644
--- a/Emby.Server.Implementations/Localization/Core/ro.json
+++ b/Emby.Server.Implementations/Localization/Core/ro.json
@@ -114,5 +114,8 @@
"TasksLibraryCategory": "Librărie",
"TasksMaintenanceCategory": "Mentenanță",
"TaskCleanActivityLogDescription": "Șterge intrările din jurnalul de activitate mai vechi de data configurată.",
- "TaskCleanActivityLog": "Curăță Jurnalul de Activitate"
+ "TaskCleanActivityLog": "Curăță Jurnalul de Activitate",
+ "Undefined": "Nedefinit",
+ "Forced": "Forțat",
+ "Default": "Implicit"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json
index c0db2cf7f..ca6172fce 100644
--- a/Emby.Server.Implementations/Localization/Core/ru.json
+++ b/Emby.Server.Implementations/Localization/Core/ru.json
@@ -115,5 +115,8 @@
"TaskRefreshChapterImagesDescription": "Создаются эскизы для видео, которые содержат сцены.",
"TaskCleanCacheDescription": "Удаляются файлы кэша, которые больше не нужны системе.",
"TaskCleanActivityLogDescription": "Удаляет записи журнала активности старше установленного возраста.",
- "TaskCleanActivityLog": "Очистить журнал активности"
+ "TaskCleanActivityLog": "Очистить журнал активности",
+ "Undefined": "Не определено",
+ "Forced": "Форсир-ые",
+ "Default": "По умолчанию"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json
index 8e5026944..99fbd3954 100644
--- a/Emby.Server.Implementations/Localization/Core/sk.json
+++ b/Emby.Server.Implementations/Localization/Core/sk.json
@@ -2,7 +2,7 @@
"Albums": "Albumy",
"AppDeviceValues": "Aplikácia: {0}, Zariadenie: {1}",
"Application": "Aplikácia",
- "Artists": "Umelci",
+ "Artists": "Interpreti",
"AuthenticationSucceededWithUserName": "{0} úspešne overený",
"Books": "Knihy",
"CameraImageUploadedFrom": "Z {0} bola nahraná nová fotografia",
@@ -15,13 +15,13 @@
"Favorites": "Obľúbené",
"Folders": "Priečinky",
"Genres": "Žánre",
- "HeaderAlbumArtists": "Umelci albumu",
+ "HeaderAlbumArtists": "Interpreti albumu",
"HeaderContinueWatching": "Pokračovať v pozeraní",
"HeaderFavoriteAlbums": "Obľúbené albumy",
- "HeaderFavoriteArtists": "Obľúbení umelci",
+ "HeaderFavoriteArtists": "Obľúbení interpreti",
"HeaderFavoriteEpisodes": "Obľúbené epizódy",
"HeaderFavoriteShows": "Obľúbené seriály",
- "HeaderFavoriteSongs": "Obľúbené piesne",
+ "HeaderFavoriteSongs": "Obľúbené skladby",
"HeaderLiveTV": "Živá TV",
"HeaderNextUp": "Nasleduje",
"HeaderRecordingGroups": "Skupiny nahrávok",
@@ -33,13 +33,13 @@
"LabelRunningTimeValue": "Dĺžka: {0}",
"Latest": "Najnovšie",
"MessageApplicationUpdated": "Jellyfin Server bol aktualizovaný",
- "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizový na verziu {0}",
+ "MessageApplicationUpdatedTo": "Jellyfin Server bol aktualizovaný na verziu {0}",
"MessageNamedServerConfigurationUpdatedWithValue": "Sekcia {0} konfigurácie servera bola aktualizovaná",
"MessageServerConfigurationUpdated": "Konfigurácia servera bola aktualizovaná",
"MixedContent": "Zmiešaný obsah",
"Movies": "Filmy",
"Music": "Hudba",
- "MusicVideos": "Hudobné videá",
+ "MusicVideos": "Hudobné videoklipy",
"NameInstallFailed": "Inštalácia {0} zlyhala",
"NameSeasonNumber": "Séria {0}",
"NameSeasonUnknown": "Neznáma séria",
@@ -71,7 +71,7 @@
"ScheduledTaskStartedWithName": "{0} zahájených",
"ServerNameNeedsToBeRestarted": "{0} vyžaduje reštart",
"Shows": "Seriály",
- "Songs": "Piesne",
+ "Songs": "Skladby",
"StartupEmbyServerIsLoading": "Jellyfin Server sa spúšťa. Prosím, skúste to o chvíľu znova.",
"SubtitleDownloadFailureForItem": "Sťahovanie titulkov pre {0} zlyhalo",
"SubtitleDownloadFailureFromForItem": "Sťahovanie titulkov z {0} pre {1} zlyhalo",
@@ -89,29 +89,34 @@
"UserPolicyUpdatedWithName": "Používateľské zásady pre {0} boli aktualizované",
"UserStartedPlayingItemWithValues": "{0} spustil prehrávanie {1} na {2}",
"UserStoppedPlayingItemWithValues": "{0} ukončil prehrávanie {1} na {2}",
- "ValueHasBeenAddedToLibrary": "{0} bol pridané do vašej knižnice médií",
+ "ValueHasBeenAddedToLibrary": "{0} bol pridaný do vašej knižnice médií",
"ValueSpecialEpisodeName": "Špeciál - {0}",
"VersionNumber": "Verzia {0}",
"TaskDownloadMissingSubtitlesDescription": "Vyhľadá na internete chýbajúce titulky podľa toho, ako sú nakonfigurované metadáta.",
"TaskDownloadMissingSubtitles": "Stiahnuť chýbajúce titulky",
"TaskRefreshChannelsDescription": "Obnoví informácie o internetových kanáloch.",
"TaskRefreshChannels": "Obnoviť kanály",
- "TaskCleanTranscodeDescription": "Vymaže súbory transkódovania, ktoré sú staršie ako jeden deň.",
- "TaskCleanTranscode": "Vyčistiť priečinok pre transkódovanie",
+ "TaskCleanTranscodeDescription": "Vymaže prekódované súbory, ktoré sú staršie ako jeden deň.",
+ "TaskCleanTranscode": "Vyčistiť priečinok pre prekódovanie",
"TaskUpdatePluginsDescription": "Stiahne a nainštaluje aktualizácie pre zásuvné moduly, ktoré sú nastavené tak, aby sa aktualizovali automaticky.",
"TaskUpdatePlugins": "Aktualizovať zásuvné moduly",
"TaskRefreshPeopleDescription": "Aktualizuje metadáta pre hercov a režisérov vo vašej mediálnej knižnici.",
"TaskRefreshPeople": "Obnoviť osoby",
- "TaskCleanLogsDescription": "Vymaže log súbory, ktoré su staršie ako {0} deň/dni/dní.",
+ "TaskCleanLogsDescription": "Vymaže log súbory, ktoré sú staršie ako {0} deň/dni/dní.",
"TaskCleanLogs": "Vyčistiť priečinok s logmi",
"TaskRefreshLibraryDescription": "Hľadá vo vašej mediálnej knižnici nové súbory a obnovuje metadáta.",
"TaskRefreshLibrary": "Prehľadávať knižnicu medií",
"TaskRefreshChapterImagesDescription": "Vytvorí náhľady pre videá, ktoré majú kapitoly.",
"TaskRefreshChapterImages": "Extrahovať obrázky kapitol",
- "TaskCleanCacheDescription": "Vymaže cache súbory, ktoré nie sú už potrebné pre systém.",
- "TaskCleanCache": "Vyčistiť Cache priečinok",
+ "TaskCleanCacheDescription": "Vymaže súbory vyrovnávacej pamäte, ktoré už nie sú potrebné pre systém.",
+ "TaskCleanCache": "Vyčistiť priečinok vyrovnávacej pamäte",
"TasksChannelsCategory": "Internetové kanály",
"TasksApplicationCategory": "Aplikácia",
"TasksLibraryCategory": "Knižnica",
- "TasksMaintenanceCategory": "Údržba"
+ "TasksMaintenanceCategory": "Údržba",
+ "TaskCleanActivityLogDescription": "Vymaže záznamy aktivít v logu, ktoré sú staršie ako zadaná doba.",
+ "TaskCleanActivityLog": "Vyčistiť log aktivít",
+ "Undefined": "Nedefinované",
+ "Forced": "Vynútené",
+ "Default": "Predvolené"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json
index e8cd23d5d..c737ba42b 100644
--- a/Emby.Server.Implementations/Localization/Core/ta.json
+++ b/Emby.Server.Implementations/Localization/Core/ta.json
@@ -21,7 +21,7 @@
"Inherit": "மரபுரிமையாகப் பெறு",
"HeaderRecordingGroups": "பதிவு குழுக்கள்",
"Folders": "கோப்புறைகள்",
- "FailedLoginAttemptWithUserName": "{0} இலிருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது",
+ "FailedLoginAttemptWithUserName": "{0} இல் இருந்து உள்நுழைவு முயற்சி தோல்வியடைந்தது",
"DeviceOnlineWithName": "{0} இணைக்கப்பட்டது",
"DeviceOfflineWithName": "{0} துண்டிக்கப்பட்டது",
"Collections": "தொகுப்புகள்",
@@ -99,7 +99,7 @@
"MessageNamedServerConfigurationUpdatedWithValue": "சேவையக உள்ளமைவு பிரிவு {0} புதுப்பிக்கப்பட்டது",
"TaskCleanCacheDescription": "கணினிக்கு இனி தேவைப்படாத தற்காலிக கோப்புகளை நீக்கு.",
"UserOfflineFromDevice": "{0} இலிருந்து {1} துண்டிக்கப்பட்டுள்ளது",
- "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இலிருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன",
+ "SubtitleDownloadFailureFromForItem": "வசன வரிகள் {0} இல் இருந்து {1} க்கு பதிவிறக்கத் தவறிவிட்டன",
"TaskDownloadMissingSubtitlesDescription": "மீத்தரவு உள்ளமைவின் அடிப்படையில் வசன வரிகள் காணாமல் போனதற்கு இணையத்தைத் தேடுகிறது.",
"TaskCleanTranscodeDescription": "ஒரு நாளைக்கு மேற்பட்ட பழைய டிரான்ஸ்கோட் கோப்புகளை நீக்குகிறது.",
"TaskUpdatePluginsDescription": "தானாகவே புதுப்பிக்க கட்டமைக்கப்பட்ட உட்செருகிகளுக்கான புதுப்பிப்புகளை பதிவிறக்குகிறது மற்றும் நிறுவுகிறது.",
@@ -114,5 +114,8 @@
"UserStoppedPlayingItemWithValues": "{0} {2} இல் {1} முடித்துவிட்டது",
"UserStartedPlayingItemWithValues": "{0} {2}இல் {1} ஐ இயக்குகிறது",
"TaskCleanActivityLogDescription": "உள்ளமைக்கப்பட்ட வயதை விட பழைய செயல்பாட்டு பதிவு உள்ளீடுகளை நீக்குகிறது.",
- "TaskCleanActivityLog": "செயல்பாட்டு பதிவை அழி"
+ "TaskCleanActivityLog": "செயல்பாட்டு பதிவை அழி",
+ "Undefined": "வரையறுக்கப்படாத",
+ "Forced": "கட்டாயப்படுத்தப்பட்டது",
+ "Default": "இயல்புநிலை"
}
diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json
index 54d3a65f0..885663eed 100644
--- a/Emby.Server.Implementations/Localization/Core/tr.json
+++ b/Emby.Server.Implementations/Localization/Core/tr.json
@@ -12,7 +12,7 @@
"DeviceOfflineWithName": "{0} bağlantısı kesildi",
"DeviceOnlineWithName": "{0} bağlı",
"FailedLoginAttemptWithUserName": "{0} adresinden giriş başarısız oldu",
- "Favorites": "Favoriler",
+ "Favorites": "Favorilerim",
"Folders": "Klasörler",
"Genres": "Türler",
"HeaderAlbumArtists": "Albüm Sanatçıları",
@@ -115,5 +115,7 @@
"TaskRefreshChapterImages": "Bölüm Resimlerini Çıkar",
"TaskCleanCacheDescription": "Sistem tarafından artık ihtiyaç duyulmayan önbellek dosyalarını siler.",
"TaskCleanActivityLog": "İşlem Günlüğünü Temizle",
- "TaskCleanActivityLogDescription": "Belirtilen sureden daha eski etkinlik log kayıtları silindi."
+ "TaskCleanActivityLogDescription": "Belirtilen sureden daha eski etkinlik log kayıtları silindi.",
+ "Undefined": "Bilinmeyen",
+ "Default": "Varsayılan"
}
diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json
index 06cc5f633..b6073bf6a 100644
--- a/Emby.Server.Implementations/Localization/Core/uk.json
+++ b/Emby.Server.Implementations/Localization/Core/uk.json
@@ -27,7 +27,7 @@
"Channels": "Канали",
"CameraImageUploadedFrom": "Нова фотографія завантажена з {0}",
"Books": "Книги",
- "AuthenticationSucceededWithUserName": "{0} успішно авторизований",
+ "AuthenticationSucceededWithUserName": "{0} успішно автентифіковано",
"Artists": "Виконавці",
"Application": "Додаток",
"AppDeviceValues": "Додаток: {0}, Пристрій: {1}",
@@ -112,5 +112,10 @@
"MessageServerConfigurationUpdated": "Конфігурація сервера оновлена",
"MessageNamedServerConfigurationUpdatedWithValue": "Розділ конфігурації сервера {0} оновлено",
"Inherit": "Успадкувати",
- "HeaderRecordingGroups": "Групи запису"
+ "HeaderRecordingGroups": "Групи запису",
+ "Forced": "Примусово",
+ "TaskCleanActivityLogDescription": "Видаляє старші за встановлений термін записи з журналу активності.",
+ "TaskCleanActivityLog": "Очистити журнал активності",
+ "Undefined": "Не визначено",
+ "Default": "За замовчуванням"
}
diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json
index ba58e4beb..40368d464 100644
--- a/Emby.Server.Implementations/Localization/Core/vi.json
+++ b/Emby.Server.Implementations/Localization/Core/vi.json
@@ -16,7 +16,7 @@
"Albums": "Albums",
"Artists": "Các Nghệ Sĩ",
"TaskDownloadMissingSubtitlesDescription": "Tìm kiếm phụ đề bị thiếu trên Internet dựa trên cấu hình dữ liệu mô tả.",
- "TaskDownloadMissingSubtitles": "Tải xuống phụ đề bị thiếu",
+ "TaskDownloadMissingSubtitles": "Tải Xuống Phụ Đề Bị Thiếu",
"TaskRefreshChannelsDescription": "Làm mới thông tin kênh internet.",
"TaskRefreshChannels": "Làm Mới Kênh",
"TaskCleanTranscodeDescription": "Xóa các tệp chuyển mã cũ hơn một ngày.",
@@ -24,11 +24,11 @@
"TaskUpdatePluginsDescription": "Tải xuống và cài đặt các bản cập nhật cho các plugin được định cấu hình để cập nhật tự động.",
"TaskUpdatePlugins": "Cập Nhật Plugins",
"TaskRefreshPeopleDescription": "Cập nhật thông tin chi tiết cho diễn viên và đạo diễn trong thư viện phương tiện của bạn.",
- "TaskRefreshPeople": "Làm mới Người dùng",
+ "TaskRefreshPeople": "Làm Mới Người Dùng",
"TaskCleanLogsDescription": "Xóa tập tin nhật ký cũ hơn {0} ngày.",
- "TaskCleanLogs": "Làm sạch nhật ký",
- "TaskRefreshLibraryDescription": "Quét thư viện phương tiện của bạn để tìm các tệp mới và làm mới thông tin chi tiết.",
- "TaskRefreshLibrary": "Quét Thư viện Phương tiện",
+ "TaskCleanLogs": "Làm Sạch Thư Mục Nhật Ký",
+ "TaskRefreshLibraryDescription": "Quét thư viện phương tiện của bạn để tìm tệp mới và làm mới dữ liệu mô tả.",
+ "TaskRefreshLibrary": "Quét Thư Viện Phương Tiện",
"TaskRefreshChapterImagesDescription": "Tạo hình thu nhỏ cho video có các phân cảnh.",
"TaskRefreshChapterImages": "Trích Xuất Ảnh Phân Cảnh",
"TaskCleanCacheDescription": "Xóa các tệp cache không còn cần thiết của hệ thống.",
@@ -80,7 +80,7 @@
"NotificationOptionApplicationUpdateAvailable": "Bản cập nhật ứng dụng hiện sẵn có",
"NewVersionIsAvailable": "Một phiên bản mới của Jellyfin Server sẵn có để tải.",
"NameSeasonUnknown": "Không Rõ Mùa",
- "NameSeasonNumber": "Mùa {0}",
+ "NameSeasonNumber": "Phần {0}",
"NameInstallFailed": "{0} cài đặt thất bại",
"MusicVideos": "Video Nhạc",
"Music": "Nhạc",
@@ -114,5 +114,8 @@
"Application": "Ứng Dụng",
"AppDeviceValues": "Ứng Dụng: {0}, Thiết Bị: {1}",
"TaskCleanActivityLogDescription": "Xóa các mục nhật ký hoạt động cũ hơn độ tuổi đã cài đặt.",
- "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động"
+ "TaskCleanActivityLog": "Xóa Nhật Ký Hoạt Động",
+ "Undefined": "Không Xác Định",
+ "Forced": "Bắt Buộc",
+ "Default": "Mặc Định"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json
index 3ae0fe5e7..12803456e 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-CN.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json
@@ -115,5 +115,8 @@
"TasksApplicationCategory": "应用程序",
"TasksMaintenanceCategory": "维护",
"TaskCleanActivityLog": "清理程序日志",
- "TaskCleanActivityLogDescription": "删除早于设置时间的活动日志条目。"
+ "TaskCleanActivityLogDescription": "删除早于设置时间的活动日志条目。",
+ "Undefined": "未定义",
+ "Forced": "强制的",
+ "Default": "默认"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json
index d2e3d77a3..6494c0b54 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-TW.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json
@@ -114,5 +114,8 @@
"TasksApplicationCategory": "應用程式",
"TasksMaintenanceCategory": "維護",
"TaskCleanActivityLogDescription": "刪除超過所設時間的活動紀錄。",
- "TaskCleanActivityLog": "清除活動紀錄"
+ "TaskCleanActivityLog": "清除活動紀錄",
+ "Undefined": "未定義的",
+ "Forced": "強制",
+ "Default": "原本"
}
diff --git a/Emby.Server.Implementations/Localization/countries.json b/Emby.Server.Implementations/Localization/countries.json
index 581e9f835..b08a3ae79 100644
--- a/Emby.Server.Implementations/Localization/countries.json
+++ b/Emby.Server.Implementations/Localization/countries.json
@@ -558,6 +558,12 @@
"TwoLetterISORegionName": "OM"
},
{
+ "DisplayName": "Palestine",
+ "Name": "PS",
+ "ThreeLetterISORegionName": "PSE",
+ "TwoLetterISORegionName": "PS"
+ },
+ {
"DisplayName": "Panama",
"Name": "PA",
"ThreeLetterISORegionName": "PAN",
diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
index 438bbe24a..f27305cbe 100644
--- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
+++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs
@@ -12,6 +12,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
@@ -81,12 +82,7 @@ namespace Emby.Server.Implementations.MediaEncoder
return false;
}
- if (video.VideoType == VideoType.Iso)
- {
- return false;
- }
-
- if (video.VideoType == VideoType.BluRay || video.VideoType == VideoType.Dvd)
+ if (video.VideoType == VideoType.Dvd)
{
return false;
}
@@ -140,15 +136,19 @@ namespace Emby.Server.Implementations.MediaEncoder
// Add some time for the first chapter to make sure we don't end up with a black image
var time = chapter.StartPositionTicks == 0 ? TimeSpan.FromTicks(Math.Min(_firstChapterTicks, video.RunTimeTicks ?? 0)) : TimeSpan.FromTicks(chapter.StartPositionTicks);
- var protocol = MediaProtocol.File;
-
- var inputPath = MediaEncoderHelpers.GetInputArgument(_fileSystem, video.Path, null, Array.Empty<string>());
+ var inputPath = video.Path;
Directory.CreateDirectory(Path.GetDirectoryName(path));
var container = video.Container;
+ var mediaSource = new MediaSourceInfo
+ {
+ VideoType = video.VideoType,
+ IsoType = video.IsoType,
+ Protocol = video.PathProtocol.Value,
+ };
- var tempFile = await _encoder.ExtractVideoImage(inputPath, container, protocol, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
+ var tempFile = await _encoder.ExtractVideoImage(inputPath, container, mediaSource, video.GetDefaultVideoStream(), video.Video3DFormat, time, cancellationToken).ConfigureAwait(false);
File.Copy(tempFile, path, true);
try
diff --git a/Emby.Server.Implementations/Networking/NetworkManager.cs b/Emby.Server.Implementations/Networking/NetworkManager.cs
deleted file mode 100644
index 089ec30e6..000000000
--- a/Emby.Server.Implementations/Networking/NetworkManager.cs
+++ /dev/null
@@ -1,556 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net;
-using System.Net.NetworkInformation;
-using System.Net.Sockets;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using Microsoft.Extensions.Logging;
-
-namespace Emby.Server.Implementations.Networking
-{
- /// <summary>
- /// Class to take care of network interface management.
- /// </summary>
- public class NetworkManager : INetworkManager
- {
- private readonly ILogger<NetworkManager> _logger;
-
- private IPAddress[] _localIpAddresses;
- private readonly object _localIpAddressSyncLock = new object();
-
- private readonly object _subnetLookupLock = new object();
- private readonly Dictionary<string, List<string>> _subnetLookup = new Dictionary<string, List<string>>(StringComparer.Ordinal);
-
- private List<PhysicalAddress> _macAddresses;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="NetworkManager"/> class.
- /// </summary>
- /// <param name="logger">Logger to use for messages.</param>
- public NetworkManager(ILogger<NetworkManager> logger)
- {
- _logger = logger;
-
- NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
- NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
- }
-
- /// <inheritdoc/>
- public event EventHandler NetworkChanged;
-
- /// <inheritdoc/>
- public Func<string[]> LocalSubnetsFn { get; set; }
-
- private void OnNetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e)
- {
- _logger.LogDebug("NetworkAvailabilityChanged");
- OnNetworkChanged();
- }
-
- private void OnNetworkAddressChanged(object sender, EventArgs e)
- {
- _logger.LogDebug("NetworkAddressChanged");
- OnNetworkChanged();
- }
-
- private void OnNetworkChanged()
- {
- lock (_localIpAddressSyncLock)
- {
- _localIpAddresses = null;
- _macAddresses = null;
- }
-
- NetworkChanged?.Invoke(this, EventArgs.Empty);
- }
-
- /// <inheritdoc/>
- public IPAddress[] GetLocalIpAddresses()
- {
- lock (_localIpAddressSyncLock)
- {
- if (_localIpAddresses == null)
- {
- var addresses = GetLocalIpAddressesInternal().ToArray();
-
- _localIpAddresses = addresses;
- }
-
- return _localIpAddresses;
- }
- }
-
- private List<IPAddress> GetLocalIpAddressesInternal()
- {
- var list = GetIPsDefault().ToList();
-
- if (list.Count == 0)
- {
- list = GetLocalIpAddressesFallback().GetAwaiter().GetResult().ToList();
- }
-
- var listClone = new List<IPAddress>();
-
- var subnets = LocalSubnetsFn();
-
- foreach (var i in list)
- {
- if (i.IsIPv6LinkLocal || i.ToString().StartsWith("169.254.", StringComparison.OrdinalIgnoreCase))
- {
- continue;
- }
-
- if (Array.IndexOf(subnets, $"[{i}]") == -1)
- {
- listClone.Add(i);
- }
- }
-
- return listClone
- .OrderBy(i => i.AddressFamily == AddressFamily.InterNetwork ? 0 : 1)
- // .ThenBy(i => listClone.IndexOf(i))
- .GroupBy(i => i.ToString())
- .Select(x => x.First())
- .ToList();
- }
-
- /// <inheritdoc/>
- public bool IsInPrivateAddressSpace(string endpoint)
- {
- return IsInPrivateAddressSpace(endpoint, true);
- }
-
- // Checks if the address in endpoint is an RFC1918, RFC1122, or RFC3927 address
- private bool IsInPrivateAddressSpace(string endpoint, bool checkSubnets)
- {
- if (string.Equals(endpoint, "::1", StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
- // IPV6
- if (endpoint.Split('.').Length > 4)
- {
- // Handle ipv4 mapped to ipv6
- var originalEndpoint = endpoint;
- endpoint = endpoint.Replace("::ffff:", string.Empty, StringComparison.OrdinalIgnoreCase);
-
- if (string.Equals(endpoint, originalEndpoint, StringComparison.OrdinalIgnoreCase))
- {
- return false;
- }
- }
-
- // Private address space:
-
- if (string.Equals(endpoint, "localhost", StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
- if (!IPAddress.TryParse(endpoint, out var ipAddress))
- {
- return false;
- }
-
- byte[] octet = ipAddress.GetAddressBytes();
-
- if ((octet[0] == 10) ||
- (octet[0] == 172 && (octet[1] >= 16 && octet[1] <= 31)) || // RFC1918
- (octet[0] == 192 && octet[1] == 168) || // RFC1918
- (octet[0] == 127) || // RFC1122
- (octet[0] == 169 && octet[1] == 254)) // RFC3927
- {
- return true;
- }
-
- if (checkSubnets && IsInPrivateAddressSpaceAndLocalSubnet(endpoint))
- {
- return true;
- }
-
- return false;
- }
-
- /// <inheritdoc/>
- public bool IsInPrivateAddressSpaceAndLocalSubnet(string endpoint)
- {
- if (endpoint.StartsWith("10.", StringComparison.OrdinalIgnoreCase))
- {
- var endpointFirstPart = endpoint.Split('.')[0];
-
- var subnets = GetSubnets(endpointFirstPart);
-
- foreach (var subnet_Match in subnets)
- {
- // logger.LogDebug("subnet_Match:" + subnet_Match);
-
- if (endpoint.StartsWith(subnet_Match + ".", StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
- }
- }
-
- return false;
- }
-
- // Gives a list of possible subnets from the system whose interface ip starts with endpointFirstPart
- private List<string> GetSubnets(string endpointFirstPart)
- {
- lock (_subnetLookupLock)
- {
- if (_subnetLookup.TryGetValue(endpointFirstPart, out var subnets))
- {
- return subnets;
- }
-
- subnets = new List<string>();
-
- foreach (var adapter in NetworkInterface.GetAllNetworkInterfaces())
- {
- foreach (var unicastIPAddressInformation in adapter.GetIPProperties().UnicastAddresses)
- {
- if (unicastIPAddressInformation.Address.AddressFamily == AddressFamily.InterNetwork && endpointFirstPart == unicastIPAddressInformation.Address.ToString().Split('.')[0])
- {
- int subnet_Test = 0;
- foreach (string part in unicastIPAddressInformation.IPv4Mask.ToString().Split('.'))
- {
- if (part.Equals("0", StringComparison.Ordinal))
- {
- break;
- }
-
- subnet_Test++;
- }
-
- var subnet_Match = string.Join(".", unicastIPAddressInformation.Address.ToString().Split('.').Take(subnet_Test).ToArray());
-
- // TODO: Is this check necessary?
- if (adapter.OperationalStatus == OperationalStatus.Up)
- {
- subnets.Add(subnet_Match);
- }
- }
- }
- }
-
- _subnetLookup[endpointFirstPart] = subnets;
-
- return subnets;
- }
- }
-
- /// <inheritdoc/>
- public bool IsInLocalNetwork(string endpoint)
- {
- return IsInLocalNetworkInternal(endpoint, true);
- }
-
- /// <inheritdoc/>
- public bool IsAddressInSubnets(string addressString, string[] subnets)
- {
- return IsAddressInSubnets(IPAddress.Parse(addressString), addressString, subnets);
- }
-
- /// <inheritdoc/>
- public bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC)
- {
- byte[] octet = address.GetAddressBytes();
-
- if ((octet[0] == 127) || // RFC1122
- (octet[0] == 169 && octet[1] == 254)) // RFC3927
- {
- // don't use on loopback or 169 interfaces
- return false;
- }
-
- string addressString = address.ToString();
- string excludeAddress = "[" + addressString + "]";
- var subnets = LocalSubnetsFn();
-
- // Include any address if LAN subnets aren't specified
- if (subnets.Length == 0)
- {
- return true;
- }
-
- // Exclude any addresses if they appear in the LAN list in [ ]
- if (Array.IndexOf(subnets, excludeAddress) != -1)
- {
- return false;
- }
-
- return IsAddressInSubnets(address, addressString, subnets);
- }
-
- /// <summary>
- /// Checks if the give address falls within the ranges given in [subnets]. The addresses in subnets can be hosts or subnets in the CIDR format.
- /// </summary>
- /// <param name="address">IPAddress version of the address.</param>
- /// <param name="addressString">The address to check.</param>
- /// <param name="subnets">If true, check against addresses in the LAN settings which have [] arroud and return true if it matches the address give in address.</param>
- /// <returns><c>false</c>if the address isn't in the subnets, <c>true</c> otherwise.</returns>
- private static bool IsAddressInSubnets(IPAddress address, string addressString, string[] subnets)
- {
- foreach (var subnet in subnets)
- {
- var normalizedSubnet = subnet.Trim();
- // Is the subnet a host address and does it match the address being passes?
- if (string.Equals(normalizedSubnet, addressString, StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
-
- // Parse CIDR subnets and see if address falls within it.
- if (normalizedSubnet.Contains('/', StringComparison.Ordinal))
- {
- try
- {
- var ipNetwork = IPNetwork.Parse(normalizedSubnet);
- if (ipNetwork.Contains(address))
- {
- return true;
- }
- }
- catch
- {
- // Ignoring - invalid subnet passed encountered.
- }
- }
- }
-
- return false;
- }
-
- private bool IsInLocalNetworkInternal(string endpoint, bool resolveHost)
- {
- if (string.IsNullOrEmpty(endpoint))
- {
- throw new ArgumentNullException(nameof(endpoint));
- }
-
- if (IPAddress.TryParse(endpoint, out var address))
- {
- var addressString = address.ToString();
-
- var localSubnetsFn = LocalSubnetsFn;
- if (localSubnetsFn != null)
- {
- var localSubnets = localSubnetsFn();
- foreach (var subnet in localSubnets)
- {
- // Only validate if there's at least one valid entry.
- if (!string.IsNullOrWhiteSpace(subnet))
- {
- return IsAddressInSubnets(address, addressString, localSubnets) || IsInPrivateAddressSpace(addressString, false);
- }
- }
- }
-
- int lengthMatch = 100;
- if (address.AddressFamily == AddressFamily.InterNetwork)
- {
- lengthMatch = 4;
- if (IsInPrivateAddressSpace(addressString, true))
- {
- return true;
- }
- }
- else if (address.AddressFamily == AddressFamily.InterNetworkV6)
- {
- lengthMatch = 9;
- if (IsInPrivateAddressSpace(endpoint, true))
- {
- return true;
- }
- }
-
- // Should be even be doing this with ipv6?
- if (addressString.Length >= lengthMatch)
- {
- var prefix = addressString.Substring(0, lengthMatch);
-
- if (GetLocalIpAddresses().Any(i => i.ToString().StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
- {
- return true;
- }
- }
- }
- else if (resolveHost)
- {
- if (Uri.TryCreate(endpoint, UriKind.RelativeOrAbsolute, out var uri))
- {
- try
- {
- var host = uri.DnsSafeHost;
- _logger.LogDebug("Resolving host {0}", host);
-
- address = GetIpAddresses(host).GetAwaiter().GetResult().FirstOrDefault();
-
- if (address != null)
- {
- _logger.LogDebug("{0} resolved to {1}", host, address);
-
- return IsInLocalNetworkInternal(address.ToString(), false);
- }
- }
- catch (InvalidOperationException)
- {
- // Can happen with reverse proxy or IIS url rewriting?
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error resolving hostname");
- }
- }
- }
-
- return false;
- }
-
- private static Task<IPAddress[]> GetIpAddresses(string hostName)
- {
- return Dns.GetHostAddressesAsync(hostName);
- }
-
- private IEnumerable<IPAddress> GetIPsDefault()
- {
- IEnumerable<NetworkInterface> interfaces;
-
- try
- {
- interfaces = NetworkInterface.GetAllNetworkInterfaces()
- .Where(x => x.OperationalStatus == OperationalStatus.Up
- || x.OperationalStatus == OperationalStatus.Unknown);
- }
- catch (NetworkInformationException ex)
- {
- _logger.LogError(ex, "Error in GetAllNetworkInterfaces");
- return Enumerable.Empty<IPAddress>();
- }
-
- return interfaces.SelectMany(network =>
- {
- var ipProperties = network.GetIPProperties();
-
- // Exclude any addresses if they appear in the LAN list in [ ]
-
- return ipProperties.UnicastAddresses
- .Select(i => i.Address)
- .Where(i => i.AddressFamily == AddressFamily.InterNetwork || i.AddressFamily == AddressFamily.InterNetworkV6);
- }).GroupBy(i => i.ToString())
- .Select(x => x.First());
- }
-
- private static async Task<IEnumerable<IPAddress>> GetLocalIpAddressesFallback()
- {
- var host = await Dns.GetHostEntryAsync(Dns.GetHostName()).ConfigureAwait(false);
-
- // Reverse them because the last one is usually the correct one
- // It's not fool-proof so ultimately the consumer will have to examine them and decide
- return host.AddressList
- .Where(i => i.AddressFamily == AddressFamily.InterNetwork || i.AddressFamily == AddressFamily.InterNetworkV6)
- .Reverse();
- }
-
- /// <summary>
- /// Gets a random port number that is currently available.
- /// </summary>
- /// <returns>System.Int32.</returns>
- public int GetRandomUnusedTcpPort()
- {
- var listener = new TcpListener(IPAddress.Any, 0);
- listener.Start();
- var port = ((IPEndPoint)listener.LocalEndpoint).Port;
- listener.Stop();
- return port;
- }
-
- /// <inheritdoc/>
- public int GetRandomUnusedUdpPort()
- {
- var localEndPoint = new IPEndPoint(IPAddress.Any, 0);
- using (var udpClient = new UdpClient(localEndPoint))
- {
- return ((IPEndPoint)udpClient.Client.LocalEndPoint).Port;
- }
- }
-
- /// <inheritdoc/>
- public List<PhysicalAddress> GetMacAddresses()
- {
- return _macAddresses ??= GetMacAddressesInternal().ToList();
- }
-
- private static IEnumerable<PhysicalAddress> GetMacAddressesInternal()
- => NetworkInterface.GetAllNetworkInterfaces()
- .Where(i => i.NetworkInterfaceType != NetworkInterfaceType.Loopback)
- .Select(x => x.GetPhysicalAddress())
- .Where(x => !x.Equals(PhysicalAddress.None));
-
- /// <inheritdoc/>
- public bool IsInSameSubnet(IPAddress address1, IPAddress address2, IPAddress subnetMask)
- {
- IPAddress network1 = GetNetworkAddress(address1, subnetMask);
- IPAddress network2 = GetNetworkAddress(address2, subnetMask);
- return network1.Equals(network2);
- }
-
- private IPAddress GetNetworkAddress(IPAddress address, IPAddress subnetMask)
- {
- byte[] ipAdressBytes = address.GetAddressBytes();
- byte[] subnetMaskBytes = subnetMask.GetAddressBytes();
-
- if (ipAdressBytes.Length != subnetMaskBytes.Length)
- {
- throw new ArgumentException("Lengths of IP address and subnet mask do not match.");
- }
-
- byte[] broadcastAddress = new byte[ipAdressBytes.Length];
- for (int i = 0; i < broadcastAddress.Length; i++)
- {
- broadcastAddress[i] = (byte)(ipAdressBytes[i] & subnetMaskBytes[i]);
- }
-
- return new IPAddress(broadcastAddress);
- }
-
- /// <inheritdoc/>
- public IPAddress GetLocalIpSubnetMask(IPAddress address)
- {
- NetworkInterface[] interfaces;
-
- try
- {
- var validStatuses = new[] { OperationalStatus.Up, OperationalStatus.Unknown };
-
- interfaces = NetworkInterface.GetAllNetworkInterfaces()
- .Where(i => validStatuses.Contains(i.OperationalStatus))
- .ToArray();
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error in GetAllNetworkInterfaces");
- return null;
- }
-
- foreach (NetworkInterface ni in interfaces)
- {
- foreach (UnicastIPAddressInformation ip in ni.GetIPProperties().UnicastAddresses)
- {
- if (ip.Address.Equals(address) && ip.IPv4Mask != null)
- {
- return ip.IPv4Mask;
- }
- }
- }
-
- return null;
- }
- }
-}
diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
index d3b64fb31..932f721ab 100644
--- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs
+++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs
@@ -150,7 +150,7 @@ namespace Emby.Server.Implementations.Playlists
await playlist.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { ForceSave = true }, CancellationToken.None)
.ConfigureAwait(false);
- if (options.ItemIdList.Length > 0)
+ if (options.ItemIdList.Count > 0)
{
await AddToPlaylistInternal(playlist.Id, options.ItemIdList, user, new DtoOptions(false)
{
@@ -184,7 +184,7 @@ namespace Emby.Server.Implementations.Playlists
return Playlist.GetPlaylistItems(playlistMediaType, items, user, options);
}
- public Task AddToPlaylistAsync(Guid playlistId, ICollection<Guid> itemIds, Guid userId)
+ public Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
{
var user = userId.Equals(Guid.Empty) ? null : _userManager.GetUserById(userId);
@@ -194,7 +194,7 @@ namespace Emby.Server.Implementations.Playlists
});
}
- private async Task AddToPlaylistInternal(Guid playlistId, ICollection<Guid> newItemIds, User user, DtoOptions options)
+ private async Task AddToPlaylistInternal(Guid playlistId, IReadOnlyCollection<Guid> newItemIds, User user, DtoOptions options)
{
// Retrieve the existing playlist
var playlist = _libraryManager.GetItemById(playlistId) as Playlist
diff --git a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs
index 140a67541..7bed06de3 100644
--- a/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs
+++ b/Emby.Server.Implementations/QuickConnect/QuickConnectManager.cs
@@ -243,7 +243,7 @@ namespace Emby.Server.Implementations.QuickConnect
Span<byte> bytes = stackalloc byte[length];
_rng.GetBytes(bytes);
- return Hex.Encode(bytes);
+ return Convert.ToHexString(bytes);
}
/// <inheritdoc/>
diff --git a/Emby.Server.Implementations/ResourceFileManager.cs b/Emby.Server.Implementations/ResourceFileManager.cs
deleted file mode 100644
index 22fc62293..000000000
--- a/Emby.Server.Implementations/ResourceFileManager.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.IO;
-using MediaBrowser.Controller;
-using MediaBrowser.Controller.Net;
-using MediaBrowser.Model.IO;
-using Microsoft.Extensions.Logging;
-
-namespace Emby.Server.Implementations
-{
- public class ResourceFileManager : IResourceFileManager
- {
- private readonly IFileSystem _fileSystem;
- private readonly ILogger<ResourceFileManager> _logger;
-
- public ResourceFileManager(ILogger<ResourceFileManager> logger, IFileSystem fileSystem)
- {
- _logger = logger;
- _fileSystem = fileSystem;
- }
-
- public string GetResourcePath(string basePath, string virtualPath)
- {
- var fullPath = Path.Combine(basePath, virtualPath.Replace('/', Path.DirectorySeparatorChar));
-
- try
- {
- fullPath = Path.GetFullPath(fullPath);
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error retrieving full path");
- }
-
- // Don't allow file system access outside of the source folder
- if (!_fileSystem.ContainsSubPath(basePath, fullPath))
- {
- throw new SecurityException("Access denied");
- }
-
- return fullPath;
- }
- }
-}
diff --git a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
index 6f81bf49b..cfbf03ddc 100644
--- a/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/TaskManager.cs
@@ -136,7 +136,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
{
var type = scheduledTask.ScheduledTask.GetType();
- _logger.LogInformation("Queueing task {0}", type.Name);
+ _logger.LogInformation("Queuing task {0}", type.Name);
lock (_taskQueue)
{
@@ -176,7 +176,7 @@ namespace Emby.Server.Implementations.ScheduledTasks
{
var type = task.ScheduledTask.GetType();
- _logger.LogInformation("Queueing task {0}", type.Name);
+ _logger.LogInformation("Queuing task {0}", type.Name);
lock (_taskQueue)
{
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
index 26ef19354..b13fc7fc6 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs
@@ -5,10 +5,10 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
-using MediaBrowser.Model.Globalization;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks
{
@@ -23,8 +23,12 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
private readonly ILocalizationManager _localization;
/// <summary>
- /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask" /> class.
+ /// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
/// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{DeleteTranscodeFileTask}"/> interface.</param>
+ /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
+ /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
+ /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
public DeleteTranscodeFileTask(
ILogger<DeleteTranscodeFileTask> logger,
IFileSystem fileSystem,
@@ -37,11 +41,42 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
_localization = localization;
}
+ /// <inheritdoc />
+ public string Name => _localization.GetLocalizedString("TaskCleanTranscode");
+
+ /// <inheritdoc />
+ public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription");
+
+ /// <inheritdoc />
+ public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
+
+ /// <inheritdoc />
+ public string Key => "DeleteTranscodeFiles";
+
+ /// <inheritdoc />
+ public bool IsHidden => false;
+
+ /// <inheritdoc />
+ public bool IsEnabled => true;
+
+ /// <inheritdoc />
+ public bool IsLogged => true;
+
/// <summary>
/// Creates the triggers that define when the task will run.
/// </summary>
/// <returns>IEnumerable{BaseTaskTrigger}.</returns>
- public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() => new List<TaskTriggerInfo>();
+ public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
+ {
+ return new[]
+ {
+ new TaskTriggerInfo
+ {
+ Type = TaskTriggerInfo.TriggerInterval,
+ IntervalTicks = TimeSpan.FromHours(24).Ticks
+ }
+ };
+ }
/// <summary>
/// Returns the task to be executed.
@@ -131,26 +166,5 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
_logger.LogError(ex, "Error deleting file {path}", path);
}
}
-
- /// <inheritdoc />
- public string Name => _localization.GetLocalizedString("TaskCleanTranscode");
-
- /// <inheritdoc />
- public string Description => _localization.GetLocalizedString("TaskCleanTranscodeDescription");
-
- /// <inheritdoc />
- public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory");
-
- /// <inheritdoc />
- public string Key => "DeleteTranscodeFiles";
-
- /// <inheritdoc />
- public bool IsHidden => false;
-
- /// <inheritdoc />
- public bool IsEnabled => true;
-
- /// <inheritdoc />
- public bool IsLogged => true;
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 607b322f2..b3965fcca 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -58,8 +58,7 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// The active connections.
/// </summary>
- private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections =
- new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new (StringComparer.OrdinalIgnoreCase);
private Timer _idleTimer;
@@ -196,7 +195,7 @@ namespace Emby.Server.Implementations.Session
{
if (!string.IsNullOrEmpty(info.DeviceId))
{
- var capabilities = GetSavedCapabilities(info.DeviceId);
+ var capabilities = _deviceManager.GetCapabilities(info.DeviceId);
if (capabilities != null)
{
@@ -1182,18 +1181,16 @@ namespace Emby.Server.Implementations.Session
}
/// <inheritdoc />
- public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken)
+ public async Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken)
{
CheckDisposed();
- var session = GetSessionToRemoteControl(sessionId);
await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
- public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken)
+ public async Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken)
{
CheckDisposed();
- var session = GetSessionToRemoteControl(sessionId);
await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false);
}
@@ -1677,27 +1674,10 @@ namespace Emby.Server.Implementations.Session
SessionInfo = session
});
- try
- {
- SaveCapabilities(session.DeviceId, capabilities);
- }
- catch (Exception ex)
- {
- _logger.LogError("Error saving device capabilities", ex);
- }
+ _deviceManager.SaveCapabilities(session.DeviceId, capabilities);
}
}
- private ClientCapabilities GetSavedCapabilities(string deviceId)
- {
- return _deviceManager.GetCapabilities(deviceId);
- }
-
- private void SaveCapabilities(string deviceId, ClientCapabilities capabilities)
- {
- _deviceManager.SaveCapabilities(deviceId, capabilities);
- }
-
/// <summary>
/// Converts a BaseItem to a BaseItemInfo.
/// </summary>
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index a5f847953..169eaefd8 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -4,7 +4,6 @@ using System.Linq;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
-using Jellyfin.Data.Events;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Net;
@@ -22,50 +21,48 @@ namespace Emby.Server.Implementations.Session
/// <summary>
/// The timeout in seconds after which a WebSocket is considered to be lost.
/// </summary>
- public const int WebSocketLostTimeout = 60;
+ private const int WebSocketLostTimeout = 60;
/// <summary>
/// The keep-alive interval factor; controls how often the watcher will check on the status of the WebSockets.
/// </summary>
- public const float IntervalFactor = 0.2f;
+ private const float IntervalFactor = 0.2f;
/// <summary>
/// The ForceKeepAlive factor; controls when a ForceKeepAlive is sent.
/// </summary>
- public const float ForceKeepAliveFactor = 0.75f;
+ private const float ForceKeepAliveFactor = 0.75f;
/// <summary>
- /// The _session manager.
+ /// Lock used for accesing the KeepAlive cancellation token.
/// </summary>
- private readonly ISessionManager _sessionManager;
+ private readonly object _keepAliveLock = new object();
/// <summary>
- /// The _logger.
+ /// The WebSocket watchlist.
/// </summary>
- private readonly ILogger<SessionWebSocketListener> _logger;
- private readonly ILoggerFactory _loggerFactory;
-
- private readonly IWebSocketManager _webSocketManager;
+ private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>();
/// <summary>
- /// The KeepAlive cancellation token.
+ /// Lock used for accessing the WebSockets watchlist.
/// </summary>
- private CancellationTokenSource _keepAliveCancellationToken;
+ private readonly object _webSocketsLock = new object();
/// <summary>
- /// Lock used for accesing the KeepAlive cancellation token.
+ /// The _session manager.
/// </summary>
- private readonly object _keepAliveLock = new object();
+ private readonly ISessionManager _sessionManager;
/// <summary>
- /// The WebSocket watchlist.
+ /// The _logger.
/// </summary>
- private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>();
+ private readonly ILogger<SessionWebSocketListener> _logger;
+ private readonly ILoggerFactory _loggerFactory;
/// <summary>
- /// Lock used for accesing the WebSockets watchlist.
+ /// The KeepAlive cancellation token.
/// </summary>
- private readonly object _webSocketsLock = new object();
+ private CancellationTokenSource _keepAliveCancellationToken;
/// <summary>
/// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
@@ -73,32 +70,42 @@ namespace Emby.Server.Implementations.Session
/// <param name="logger">The logger.</param>
/// <param name="sessionManager">The session manager.</param>
/// <param name="loggerFactory">The logger factory.</param>
- /// <param name="webSocketManager">The HTTP server.</param>
public SessionWebSocketListener(
ILogger<SessionWebSocketListener> logger,
ISessionManager sessionManager,
- ILoggerFactory loggerFactory,
- IWebSocketManager webSocketManager)
+ ILoggerFactory loggerFactory)
{
_logger = logger;
_sessionManager = sessionManager;
_loggerFactory = loggerFactory;
- _webSocketManager = webSocketManager;
+ }
- webSocketManager.WebSocketConnected += OnServerManagerWebSocketConnected;
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ StopKeepAlive();
}
- private async void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e)
+ /// <summary>
+ /// Processes the message.
+ /// </summary>
+ /// <param name="message">The message.</param>
+ /// <returns>Task.</returns>
+ public Task ProcessMessageAsync(WebSocketMessageInfo message)
+ => Task.CompletedTask;
+
+ /// <inheritdoc />
+ public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection)
{
- var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint.ToString());
+ var session = GetSession(connection.QueryString, connection.RemoteEndPoint.ToString());
if (session != null)
{
- EnsureController(session, e.Argument);
- await KeepAliveWebSocket(e.Argument).ConfigureAwait(false);
+ EnsureController(session, connection);
+ await KeepAliveWebSocket(connection).ConfigureAwait(false);
}
else
{
- _logger.LogWarning("Unable to determine session based on query string: {0}", e.Argument.QueryString);
+ _logger.LogWarning("Unable to determine session based on query string: {0}", connection.QueryString);
}
}
@@ -119,21 +126,6 @@ namespace Emby.Server.Implementations.Session
return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint);
}
- /// <inheritdoc />
- public void Dispose()
- {
- _webSocketManager.WebSocketConnected -= OnServerManagerWebSocketConnected;
- StopKeepAlive();
- }
-
- /// <summary>
- /// Processes the message.
- /// </summary>
- /// <param name="message">The message.</param>
- /// <returns>Task.</returns>
- public Task ProcessMessageAsync(WebSocketMessageInfo message)
- => Task.CompletedTask;
-
private void EnsureController(SessionInfo session, IWebSocketConnection connection)
{
var controllerInfo = session.EnsureController<WebSocketController>(
diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs
new file mode 100644
index 000000000..7c2ad2477
--- /dev/null
+++ b/Emby.Server.Implementations/SyncPlay/Group.cs
@@ -0,0 +1,674 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Data.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Controller.SyncPlay.GroupStates;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Controller.SyncPlay.Requests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace Emby.Server.Implementations.SyncPlay
+{
+ /// <summary>
+ /// Class Group.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class Group : IGroupStateContext
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<Group> _logger;
+
+ /// <summary>
+ /// The logger factory.
+ /// </summary>
+ private readonly ILoggerFactory _loggerFactory;
+
+ /// <summary>
+ /// The user manager.
+ /// </summary>
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// The session manager.
+ /// </summary>
+ private readonly ISessionManager _sessionManager;
+
+ /// <summary>
+ /// The library manager.
+ /// </summary>
+ private readonly ILibraryManager _libraryManager;
+
+ /// <summary>
+ /// The participants, or members of the group.
+ /// </summary>
+ private readonly Dictionary<string, GroupMember> _participants =
+ new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
+
+ /// <summary>
+ /// The internal group state.
+ /// </summary>
+ private IGroupState _state;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="Group" /> class.
+ /// </summary>
+ /// <param name="loggerFactory">The logger factory.</param>
+ /// <param name="userManager">The user manager.</param>
+ /// <param name="sessionManager">The session manager.</param>
+ /// <param name="libraryManager">The library manager.</param>
+ public Group(
+ ILoggerFactory loggerFactory,
+ IUserManager userManager,
+ ISessionManager sessionManager,
+ ILibraryManager libraryManager)
+ {
+ _loggerFactory = loggerFactory;
+ _userManager = userManager;
+ _sessionManager = sessionManager;
+ _libraryManager = libraryManager;
+ _logger = loggerFactory.CreateLogger<Group>();
+
+ _state = new IdleGroupState(loggerFactory);
+ }
+
+ /// <summary>
+ /// Gets the default ping value used for sessions.
+ /// </summary>
+ /// <value>The default ping.</value>
+ public long DefaultPing { get; } = 500;
+
+ /// <summary>
+ /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
+ /// </summary>
+ /// <value>The maximum time offset error.</value>
+ public long TimeSyncOffset { get; } = 2000;
+
+ /// <summary>
+ /// Gets the maximum offset error accepted for position reported by clients, in milliseconds.
+ /// </summary>
+ /// <value>The maximum offset error.</value>
+ public long MaxPlaybackOffset { get; } = 500;
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The group identifier.</value>
+ public Guid GroupId { get; } = Guid.NewGuid();
+
+ /// <summary>
+ /// Gets the group name.
+ /// </summary>
+ /// <value>The group name.</value>
+ public string GroupName { get; private set; }
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The group identifier.</value>
+ public PlayQueueManager PlayQueue { get; } = new PlayQueueManager();
+
+ /// <summary>
+ /// Gets the runtime ticks of current playing item.
+ /// </summary>
+ /// <value>The runtime ticks of current playing item.</value>
+ public long RunTimeTicks { get; private set; }
+
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; set; }
+
+ /// <summary>
+ /// Gets or sets the last activity.
+ /// </summary>
+ /// <value>The last activity.</value>
+ public DateTime LastActivity { get; set; }
+
+ /// <summary>
+ /// Adds the session to the group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ private void AddSession(SessionInfo session)
+ {
+ _participants.TryAdd(
+ session.Id,
+ new GroupMember(session)
+ {
+ Ping = DefaultPing,
+ IsBuffering = false
+ });
+ }
+
+ /// <summary>
+ /// Removes the session from the group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ private void RemoveSession(SessionInfo session)
+ {
+ _participants.Remove(session.Id);
+ }
+
+ /// <summary>
+ /// Filters sessions of this group.
+ /// </summary>
+ /// <param name="from">The current session.</param>
+ /// <param name="type">The filtering type.</param>
+ /// <returns>The list of sessions matching the filter.</returns>
+ private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, SyncPlayBroadcastType type)
+ {
+ return type switch
+ {
+ SyncPlayBroadcastType.CurrentSession => new SessionInfo[] { from },
+ SyncPlayBroadcastType.AllGroup => _participants
+ .Values
+ .Select(session => session.Session),
+ SyncPlayBroadcastType.AllExceptCurrentSession => _participants
+ .Values
+ .Select(session => session.Session)
+ .Where(session => !session.Id.Equals(from.Id, StringComparison.OrdinalIgnoreCase)),
+ SyncPlayBroadcastType.AllReady => _participants
+ .Values
+ .Where(session => !session.IsBuffering)
+ .Select(session => session.Session),
+ _ => Enumerable.Empty<SessionInfo>()
+ };
+ }
+
+ /// <summary>
+ /// Checks if a given user can access all items of a given queue, that is,
+ /// the user has the required minimum parental access and has access to all required folders.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <param name="queue">The queue.</param>
+ /// <returns><c>true</c> if the user can access all the items in the queue, <c>false</c> otherwise.</returns>
+ private bool HasAccessToQueue(User user, IReadOnlyList<Guid> queue)
+ {
+ // Check if queue is empty.
+ if (queue == null || queue.Count == 0)
+ {
+ return true;
+ }
+
+ foreach (var itemId in queue)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ if (!item.IsVisibleStandalone(user))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool AllUsersHaveAccessToQueue(IReadOnlyList<Guid> queue)
+ {
+ // Check if queue is empty.
+ if (queue == null || queue.Count == 0)
+ {
+ return true;
+ }
+
+ // Get list of users.
+ var users = _participants
+ .Values
+ .Select(participant => _userManager.GetUserById(participant.Session.UserId));
+
+ // Find problematic users.
+ var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue));
+
+ // All users must be able to access the queue.
+ return !usersWithNoAccess.Any();
+ }
+
+ /// <summary>
+ /// Checks if the group is empty.
+ /// </summary>
+ /// <returns><c>true</c> if the group is empty, <c>false</c> otherwise.</returns>
+ public bool IsGroupEmpty() => _participants.Count == 0;
+
+ /// <summary>
+ /// Initializes the group with the session's info.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="request">The request.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken)
+ {
+ GroupName = request.GroupName;
+ AddSession(session);
+
+ var sessionIsPlayingAnItem = session.FullNowPlayingItem != null;
+
+ RestartCurrentItem();
+
+ if (sessionIsPlayingAnItem)
+ {
+ var playlist = session.NowPlayingQueue.Select(item => item.Id).ToList();
+ PlayQueue.Reset();
+ PlayQueue.SetPlaylist(playlist);
+ PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id);
+ RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0;
+ PositionTicks = session.PlayState.PositionTicks ?? 0;
+
+ // Maintain playstate.
+ var waitingState = new WaitingGroupState(_loggerFactory)
+ {
+ ResumePlaying = !session.PlayState.IsPaused
+ };
+ SetState(waitingState);
+ }
+
+ var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo());
+ SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+
+ _state.SessionJoined(this, _state.Type, session, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} created group {GroupId}.", session.Id, GroupId.ToString());
+ }
+
+ /// <summary>
+ /// Adds the session to the group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="request">The request.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
+ {
+ AddSession(session);
+
+ var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo());
+ SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+
+ var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName);
+ SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
+
+ _state.SessionJoined(this, _state.Type, session, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} joined group {GroupId}.", session.Id, GroupId.ToString());
+ }
+
+ /// <summary>
+ /// Removes the session from the group.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="request">The request.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ public void SessionLeave(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken)
+ {
+ _state.SessionLeaving(this, _state.Type, session, cancellationToken);
+
+ RemoveSession(session);
+
+ var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString());
+ SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+
+ var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName);
+ SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} left group {GroupId}.", session.Id, GroupId.ToString());
+ }
+
+ /// <summary>
+ /// Handles the requested action by the session.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="request">The requested action.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken)
+ {
+ // The server's job is to maintain a consistent state for clients to reference
+ // and notify clients of state changes. The actual syncing of media playback
+ // happens client side. Clients are aware of the server's time and use it to sync.
+ _logger.LogInformation("Session {SessionId} requested {RequestType} in group {GroupId} that is {StateType}.", session.Id, request.Action, GroupId.ToString(), _state.Type);
+
+ // Apply requested changes to this group given its current state.
+ // Every request has a slightly different outcome depending on the group's state.
+ // There are currently four different group states that accomplish different goals:
+ // - Idle: in this state no media is playing and clients should be idle (playback is stopped).
+ // - Waiting: in this state the group is waiting for all the clients to be ready to start the playback,
+ // that is, they've either finished loading the media for the first time or they've finished buffering.
+ // Once all clients report to be ready the group's state can change to Playing or Paused.
+ // - Playing: clients have some media loaded and playback is unpaused.
+ // - Paused: clients have some media loaded but playback is currently paused.
+ request.Apply(this, _state, session, cancellationToken);
+ }
+
+ /// <summary>
+ /// Gets the info about the group for the clients.
+ /// </summary>
+ /// <returns>The group info for the clients.</returns>
+ public GroupInfoDto GetInfo()
+ {
+ var participants = _participants.Values.Select(session => session.Session.UserName).Distinct().ToList();
+ return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow);
+ }
+
+ /// <summary>
+ /// Checks if a user has access to all content in the play queue.
+ /// </summary>
+ /// <param name="user">The user.</param>
+ /// <returns><c>true</c> if the user can access the play queue; <c>false</c> otherwise.</returns>
+ public bool HasAccessToPlayQueue(User user)
+ {
+ var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToList();
+ return HasAccessToQueue(user, items);
+ }
+
+ /// <inheritdoc />
+ public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait)
+ {
+ if (_participants.TryGetValue(session.Id, out GroupMember value))
+ {
+ value.IgnoreGroupWait = ignoreGroupWait;
+ }
+ }
+
+ /// <inheritdoc />
+ public void SetState(IGroupState state)
+ {
+ _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type);
+ this._state = state;
+ }
+
+ /// <inheritdoc />
+ public Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken)
+ {
+ IEnumerable<Task> GetTasks()
+ {
+ foreach (var session in FilterSessions(from, type))
+ {
+ yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken);
+ }
+ }
+
+ return Task.WhenAll(GetTasks());
+ }
+
+ /// <inheritdoc />
+ public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken)
+ {
+ IEnumerable<Task> GetTasks()
+ {
+ foreach (var session in FilterSessions(from, type))
+ {
+ yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken);
+ }
+ }
+
+ return Task.WhenAll(GetTasks());
+ }
+
+ /// <inheritdoc />
+ public SendCommand NewSyncPlayCommand(SendCommandType type)
+ {
+ return new SendCommand(
+ GroupId,
+ PlayQueue.GetPlayingItemPlaylistId(),
+ LastActivity,
+ type,
+ PositionTicks,
+ DateTime.UtcNow);
+ }
+
+ /// <inheritdoc />
+ public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
+ {
+ return new GroupUpdate<T>(GroupId, type, data);
+ }
+
+ /// <inheritdoc />
+ public long SanitizePositionTicks(long? positionTicks)
+ {
+ var ticks = positionTicks ?? 0;
+ return Math.Clamp(ticks, 0, RunTimeTicks);
+ }
+
+ /// <inheritdoc />
+ public void UpdatePing(SessionInfo session, long ping)
+ {
+ if (_participants.TryGetValue(session.Id, out GroupMember value))
+ {
+ value.Ping = ping;
+ }
+ }
+
+ /// <inheritdoc />
+ public long GetHighestPing()
+ {
+ long max = long.MinValue;
+ foreach (var session in _participants.Values)
+ {
+ max = Math.Max(max, session.Ping);
+ }
+
+ return max;
+ }
+
+ /// <inheritdoc />
+ public void SetBuffering(SessionInfo session, bool isBuffering)
+ {
+ if (_participants.TryGetValue(session.Id, out GroupMember value))
+ {
+ value.IsBuffering = isBuffering;
+ }
+ }
+
+ /// <inheritdoc />
+ public void SetAllBuffering(bool isBuffering)
+ {
+ foreach (var session in _participants.Values)
+ {
+ session.IsBuffering = isBuffering;
+ }
+ }
+
+ /// <inheritdoc />
+ public bool IsBuffering()
+ {
+ foreach (var session in _participants.Values)
+ {
+ if (session.IsBuffering && !session.IgnoreGroupWait)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc />
+ public bool SetPlayQueue(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks)
+ {
+ // Ignore on empty queue or invalid item position.
+ if (playQueue.Count == 0 || playingItemPosition >= playQueue.Count || playingItemPosition < 0)
+ {
+ return false;
+ }
+
+ // Check if participants can access the new playing queue.
+ if (!AllUsersHaveAccessToQueue(playQueue))
+ {
+ return false;
+ }
+
+ PlayQueue.Reset();
+ PlayQueue.SetPlaylist(playQueue);
+ PlayQueue.SetPlayingItemByIndex(playingItemPosition);
+ var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId());
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ PositionTicks = startPositionTicks;
+ LastActivity = DateTime.UtcNow;
+
+ return true;
+ }
+
+ /// <inheritdoc />
+ public bool SetPlayingItem(Guid playlistItemId)
+ {
+ var itemFound = PlayQueue.SetPlayingItemByPlaylistId(playlistItemId);
+
+ if (itemFound)
+ {
+ var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId());
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ }
+ else
+ {
+ RunTimeTicks = 0;
+ }
+
+ RestartCurrentItem();
+
+ return itemFound;
+ }
+
+ /// <inheritdoc />
+ public bool RemoveFromPlayQueue(IReadOnlyList<Guid> playlistItemIds)
+ {
+ var playingItemRemoved = PlayQueue.RemoveFromPlaylist(playlistItemIds);
+ if (playingItemRemoved)
+ {
+ var itemId = PlayQueue.GetPlayingItemId();
+ if (!itemId.Equals(Guid.Empty))
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ }
+ else
+ {
+ RunTimeTicks = 0;
+ }
+
+ RestartCurrentItem();
+ }
+
+ return playingItemRemoved;
+ }
+
+ /// <inheritdoc />
+ public bool MoveItemInPlayQueue(Guid playlistItemId, int newIndex)
+ {
+ return PlayQueue.MovePlaylistItem(playlistItemId, newIndex);
+ }
+
+ /// <inheritdoc />
+ public bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode mode)
+ {
+ // Ignore on empty list.
+ if (newItems.Count == 0)
+ {
+ return false;
+ }
+
+ // Check if participants can access the new playing queue.
+ if (!AllUsersHaveAccessToQueue(newItems))
+ {
+ return false;
+ }
+
+ if (mode.Equals(GroupQueueMode.QueueNext))
+ {
+ PlayQueue.QueueNext(newItems);
+ }
+ else
+ {
+ PlayQueue.Queue(newItems);
+ }
+
+ return true;
+ }
+
+ /// <inheritdoc />
+ public void RestartCurrentItem()
+ {
+ PositionTicks = 0;
+ LastActivity = DateTime.UtcNow;
+ }
+
+ /// <inheritdoc />
+ public bool NextItemInQueue()
+ {
+ var update = PlayQueue.Next();
+ if (update)
+ {
+ var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId());
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ RestartCurrentItem();
+ return true;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ /// <inheritdoc />
+ public bool PreviousItemInQueue()
+ {
+ var update = PlayQueue.Previous();
+ if (update)
+ {
+ var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId());
+ RunTimeTicks = item.RunTimeTicks ?? 0;
+ RestartCurrentItem();
+ return true;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ /// <inheritdoc />
+ public void SetRepeatMode(GroupRepeatMode mode)
+ {
+ PlayQueue.SetRepeatMode(mode);
+ }
+
+ /// <inheritdoc />
+ public void SetShuffleMode(GroupShuffleMode mode)
+ {
+ PlayQueue.SetShuffleMode(mode);
+ }
+
+ /// <inheritdoc />
+ public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason)
+ {
+ var startPositionTicks = PositionTicks;
+
+ if (_state.Type.Equals(GroupStateType.Playing))
+ {
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime - LastActivity;
+ // Elapsed time is negative if event happens
+ // during the delay added to account for latency.
+ // In this phase clients haven't started the playback yet.
+ // In other words, LastActivity is in the future,
+ // when playback unpause is supposed to happen.
+ // Adjust ticks only if playback actually started.
+ startPositionTicks += Math.Max(elapsedTime.Ticks, 0);
+ }
+
+ return new PlayQueueUpdate(
+ reason,
+ PlayQueue.LastChange,
+ PlayQueue.GetPlaylist(),
+ PlayQueue.PlayingItemIndex,
+ startPositionTicks,
+ PlayQueue.ShuffleMode,
+ PlayQueue.RepeatMode);
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
deleted file mode 100644
index 538479512..000000000
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
+++ /dev/null
@@ -1,514 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Controller.SyncPlay;
-using MediaBrowser.Model.Session;
-using MediaBrowser.Model.SyncPlay;
-
-namespace Emby.Server.Implementations.SyncPlay
-{
- /// <summary>
- /// Class SyncPlayController.
- /// </summary>
- /// <remarks>
- /// Class is not thread-safe, external locking is required when accessing methods.
- /// </remarks>
- public class SyncPlayController : ISyncPlayController
- {
- /// <summary>
- /// Used to filter the sessions of a group.
- /// </summary>
- private enum BroadcastType
- {
- /// <summary>
- /// All sessions will receive the message.
- /// </summary>
- AllGroup = 0,
-
- /// <summary>
- /// Only the specified session will receive the message.
- /// </summary>
- CurrentSession = 1,
-
- /// <summary>
- /// All sessions, except the current one, will receive the message.
- /// </summary>
- AllExceptCurrentSession = 2,
-
- /// <summary>
- /// Only sessions that are not buffering will receive the message.
- /// </summary>
- AllReady = 3
- }
-
- /// <summary>
- /// The session manager.
- /// </summary>
- private readonly ISessionManager _sessionManager;
-
- /// <summary>
- /// The SyncPlay manager.
- /// </summary>
- private readonly ISyncPlayManager _syncPlayManager;
-
- /// <summary>
- /// The group to manage.
- /// </summary>
- private readonly GroupInfo _group = new GroupInfo();
-
- /// <summary>
- /// Initializes a new instance of the <see cref="SyncPlayController" /> class.
- /// </summary>
- /// <param name="sessionManager">The session manager.</param>
- /// <param name="syncPlayManager">The SyncPlay manager.</param>
- public SyncPlayController(
- ISessionManager sessionManager,
- ISyncPlayManager syncPlayManager)
- {
- _sessionManager = sessionManager;
- _syncPlayManager = syncPlayManager;
- }
-
- /// <inheritdoc />
- public Guid GetGroupId() => _group.GroupId;
-
- /// <inheritdoc />
- public Guid GetPlayingItemId() => _group.PlayingItem.Id;
-
- /// <inheritdoc />
- public bool IsGroupEmpty() => _group.IsEmpty();
-
- /// <summary>
- /// Converts DateTime to UTC string.
- /// </summary>
- /// <param name="date">The date to convert.</param>
- /// <value>The UTC string.</value>
- private string DateToUTCString(DateTime date)
- {
- return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture);
- }
-
- /// <summary>
- /// Filters sessions of this group.
- /// </summary>
- /// <param name="from">The current session.</param>
- /// <param name="type">The filtering type.</param>
- /// <value>The array of sessions matching the filter.</value>
- private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type)
- {
- switch (type)
- {
- case BroadcastType.CurrentSession:
- return new SessionInfo[] { from };
- case BroadcastType.AllGroup:
- return _group.Participants.Values
- .Select(session => session.Session);
- case BroadcastType.AllExceptCurrentSession:
- return _group.Participants.Values
- .Select(session => session.Session)
- .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal));
- case BroadcastType.AllReady:
- return _group.Participants.Values
- .Where(session => !session.IsBuffering)
- .Select(session => session.Session);
- default:
- return Array.Empty<SessionInfo>();
- }
- }
-
- /// <summary>
- /// Sends a GroupUpdate message to the interested sessions.
- /// </summary>
- /// <param name="from">The current session.</param>
- /// <param name="type">The filtering type.</param>
- /// <param name="message">The message to send.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <value>The task.</value>
- private Task SendGroupUpdate<T>(SessionInfo from, BroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken)
- {
- IEnumerable<Task> GetTasks()
- {
- foreach (var session in FilterSessions(from, type))
- {
- yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken);
- }
- }
-
- return Task.WhenAll(GetTasks());
- }
-
- /// <summary>
- /// Sends a playback command to the interested sessions.
- /// </summary>
- /// <param name="from">The current session.</param>
- /// <param name="type">The filtering type.</param>
- /// <param name="message">The message to send.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <value>The task.</value>
- private Task SendCommand(SessionInfo from, BroadcastType type, SendCommand message, CancellationToken cancellationToken)
- {
- IEnumerable<Task> GetTasks()
- {
- foreach (var session in FilterSessions(from, type))
- {
- yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken);
- }
- }
-
- return Task.WhenAll(GetTasks());
- }
-
- /// <summary>
- /// Builds a new playback command with some default values.
- /// </summary>
- /// <param name="type">The command type.</param>
- /// <value>The SendCommand.</value>
- private SendCommand NewSyncPlayCommand(SendCommandType type)
- {
- return new SendCommand()
- {
- GroupId = _group.GroupId.ToString(),
- Command = type,
- PositionTicks = _group.PositionTicks,
- When = DateToUTCString(_group.LastActivity),
- EmittedAt = DateToUTCString(DateTime.UtcNow)
- };
- }
-
- /// <summary>
- /// Builds a new group update message.
- /// </summary>
- /// <param name="type">The update type.</param>
- /// <param name="data">The data to send.</param>
- /// <value>The GroupUpdate.</value>
- private GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data)
- {
- return new GroupUpdate<T>()
- {
- GroupId = _group.GroupId.ToString(),
- Type = type,
- Data = data
- };
- }
-
- /// <inheritdoc />
- public void CreateGroup(SessionInfo session, CancellationToken cancellationToken)
- {
- _group.AddSession(session);
- _syncPlayManager.AddSessionToGroup(session, this);
-
- _group.PlayingItem = session.FullNowPlayingItem;
- _group.IsPaused = session.PlayState.IsPaused;
- _group.PositionTicks = session.PlayState.PositionTicks ?? 0;
- _group.LastActivity = DateTime.UtcNow;
-
- var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow));
- SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
- }
-
- /// <inheritdoc />
- public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
- {
- if (session.NowPlayingItem?.Id == _group.PlayingItem.Id)
- {
- _group.AddSession(session);
- _syncPlayManager.AddSessionToGroup(session, this);
-
- var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow));
- SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
-
- var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName);
- SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
-
- // Syncing will happen client-side
- if (!_group.IsPaused)
- {
- var playCommand = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken);
- }
- else
- {
- var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken);
- }
- }
- else
- {
- var playRequest = new PlayRequest
- {
- ItemIds = new Guid[] { _group.PlayingItem.Id },
- StartPositionTicks = _group.PositionTicks
- };
- var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest);
- SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken);
- }
- }
-
- /// <inheritdoc />
- public void SessionLeave(SessionInfo session, CancellationToken cancellationToken)
- {
- _group.RemoveSession(session);
- _syncPlayManager.RemoveSessionFromGroup(session, this);
-
- var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks);
- SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
-
- var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName);
- SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
- }
-
- /// <inheritdoc />
- public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- // The server's job is to maintain a consistent state for clients to reference
- // and notify clients of state changes. The actual syncing of media playback
- // happens client side. Clients are aware of the server's time and use it to sync.
- switch (request.Type)
- {
- case PlaybackRequestType.Play:
- HandlePlayRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Pause:
- HandlePauseRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Seek:
- HandleSeekRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Buffer:
- HandleBufferingRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Ready:
- HandleBufferingDoneRequest(session, request, cancellationToken);
- break;
- case PlaybackRequestType.Ping:
- HandlePingUpdateRequest(session, request);
- break;
- }
- }
-
- /// <summary>
- /// Handles a play action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The play action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandlePlayRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- if (_group.IsPaused)
- {
- // Pick a suitable time that accounts for latency
- var delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing);
-
- // Unpause group and set starting point in future
- // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position)
- // The added delay does not guarantee, of course, that the command will be received in time
- // Playback synchronization will mainly happen client side
- _group.IsPaused = false;
- _group.LastActivity = DateTime.UtcNow.AddMilliseconds(
- delay);
-
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
- }
- else
- {
- // Client got lost, sending current state
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- }
-
- /// <summary>
- /// Handles a pause action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The pause action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandlePauseRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- if (!_group.IsPaused)
- {
- // Pause group and compute the media playback position
- _group.IsPaused = true;
- var currentTime = DateTime.UtcNow;
- var elapsedTime = currentTime - _group.LastActivity;
- _group.LastActivity = currentTime;
-
- // Seek only if playback actually started
- // Pause request may be issued during the delay added to account for latency
- _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0;
-
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
- }
- else
- {
- // Client got lost, sending current state
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- }
-
- /// <summary>
- /// Handles a seek action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The seek action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandleSeekRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- // Sanitize PositionTicks
- var ticks = SanitizePositionTicks(request.PositionTicks);
-
- // Pause and seek
- _group.IsPaused = true;
- _group.PositionTicks = ticks;
- _group.LastActivity = DateTime.UtcNow;
-
- var command = NewSyncPlayCommand(SendCommandType.Seek);
- SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
- }
-
- /// <summary>
- /// Handles a buffering action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The buffering action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandleBufferingRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- if (!_group.IsPaused)
- {
- // Pause group and compute the media playback position
- _group.IsPaused = true;
- var currentTime = DateTime.UtcNow;
- var elapsedTime = currentTime - _group.LastActivity;
- _group.LastActivity = currentTime;
- _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0;
-
- _group.SetBuffering(session, true);
-
- // Send pause command to all non-buffering sessions
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.AllReady, command, cancellationToken);
-
- var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName);
- SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken);
- }
- else
- {
- // Client got lost, sending current state
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- }
-
- /// <summary>
- /// Handles a buffering-done action requested by a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The buffering-done action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- private void HandleBufferingDoneRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
- {
- if (_group.IsPaused)
- {
- _group.SetBuffering(session, false);
-
- var requestTicks = SanitizePositionTicks(request.PositionTicks);
-
- var when = request.When ?? DateTime.UtcNow;
- var currentTime = DateTime.UtcNow;
- var elapsedTime = currentTime - when;
- var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime;
- var delay = _group.PositionTicks - clientPosition.Ticks;
-
- if (_group.IsBuffering())
- {
- // Others are still buffering, tell this client to pause when ready
- var command = NewSyncPlayCommand(SendCommandType.Pause);
- var pauseAtTime = currentTime.AddMilliseconds(delay);
- command.When = DateToUTCString(pauseAtTime);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- else
- {
- // Let other clients resume as soon as the buffering client catches up
- _group.IsPaused = false;
-
- if (delay > _group.GetHighestPing() * 2)
- {
- // Client that was buffering is recovering, notifying others to resume
- _group.LastActivity = currentTime.AddMilliseconds(
- delay);
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.AllExceptCurrentSession, command, cancellationToken);
- }
- else
- {
- // Client, that was buffering, resumed playback but did not update others in time
- delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing);
-
- _group.LastActivity = currentTime.AddMilliseconds(
- delay);
-
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.AllGroup, command, cancellationToken);
- }
- }
- }
- else
- {
- // Group was not waiting, make sure client has latest state
- var command = NewSyncPlayCommand(SendCommandType.Play);
- SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken);
- }
- }
-
- /// <summary>
- /// Sanitizes the PositionTicks, considers the current playing item when available.
- /// </summary>
- /// <param name="positionTicks">The PositionTicks.</param>
- /// <value>The sanitized PositionTicks.</value>
- private long SanitizePositionTicks(long? positionTicks)
- {
- var ticks = positionTicks ?? 0;
- ticks = ticks >= 0 ? ticks : 0;
- if (_group.PlayingItem != null)
- {
- var runTimeTicks = _group.PlayingItem.RunTimeTicks ?? 0;
- ticks = ticks > runTimeTicks ? runTimeTicks : ticks;
- }
-
- return ticks;
- }
-
- /// <summary>
- /// Updates ping of a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The update.</param>
- private void HandlePingUpdateRequest(SessionInfo session, PlaybackRequest request)
- {
- // Collected pings are used to account for network latency when unpausing playback
- _group.UpdatePing(session, request.Ping ?? GroupInfo.DefaultPing);
- }
-
- /// <inheritdoc />
- public GroupInfoView GetInfo()
- {
- return new GroupInfoView()
- {
- GroupId = GetGroupId().ToString(),
- PlayingItemName = _group.PlayingItem.Name,
- PlayingItemId = _group.PlayingItem.Id.ToString(),
- PositionTicks = _group.PositionTicks,
- Participants = _group.Participants.Values.Select(session => session.Session.UserName).Distinct().ToList()
- };
- }
- }
-}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
index 966ed5024..348213ee1 100644
--- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
+++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
@@ -1,13 +1,11 @@
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
using System.Threading;
-using Jellyfin.Data.Entities;
-using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
using Microsoft.Extensions.Logging;
@@ -24,6 +22,11 @@ namespace Emby.Server.Implementations.SyncPlay
private readonly ILogger<SyncPlayManager> _logger;
/// <summary>
+ /// The logger factory.
+ /// </summary>
+ private readonly ILoggerFactory _loggerFactory;
+
+ /// <summary>
/// The user manager.
/// </summary>
private readonly IUserManager _userManager;
@@ -41,18 +44,21 @@ namespace Emby.Server.Implementations.SyncPlay
/// <summary>
/// The map between sessions and groups.
/// </summary>
- private readonly Dictionary<string, ISyncPlayController> _sessionToGroupMap =
- new Dictionary<string, ISyncPlayController>(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary<string, Group> _sessionToGroupMap =
+ new ConcurrentDictionary<string, Group>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The groups.
/// </summary>
- private readonly Dictionary<Guid, ISyncPlayController> _groups =
- new Dictionary<Guid, ISyncPlayController>();
+ private readonly ConcurrentDictionary<Guid, Group> _groups =
+ new ConcurrentDictionary<Guid, Group>();
/// <summary>
- /// Lock used for accesing any group.
+ /// Lock used for accessing multiple groups at once.
/// </summary>
+ /// <remarks>
+ /// This lock has priority on locks made on <see cref="Group"/>.
+ /// </remarks>
private readonly object _groupsLock = new object();
private bool _disposed = false;
@@ -60,31 +66,24 @@ namespace Emby.Server.Implementations.SyncPlay
/// <summary>
/// Initializes a new instance of the <see cref="SyncPlayManager" /> class.
/// </summary>
- /// <param name="logger">The logger.</param>
+ /// <param name="loggerFactory">The logger factory.</param>
/// <param name="userManager">The user manager.</param>
/// <param name="sessionManager">The session manager.</param>
/// <param name="libraryManager">The library manager.</param>
public SyncPlayManager(
- ILogger<SyncPlayManager> logger,
+ ILoggerFactory loggerFactory,
IUserManager userManager,
ISessionManager sessionManager,
ILibraryManager libraryManager)
{
- _logger = logger;
+ _loggerFactory = loggerFactory;
_userManager = userManager;
_sessionManager = sessionManager;
_libraryManager = libraryManager;
-
- _sessionManager.SessionEnded += OnSessionManagerSessionEnded;
- _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped;
+ _logger = loggerFactory.CreateLogger<SyncPlayManager>();
+ _sessionManager.SessionStarted += OnSessionManagerSessionStarted;
}
- /// <summary>
- /// Gets all groups.
- /// </summary>
- /// <value>All groups.</value>
- public IEnumerable<ISyncPlayController> Groups => _groups.Values;
-
/// <inheritdoc />
public void Dispose()
{
@@ -92,286 +91,256 @@ namespace Emby.Server.Implementations.SyncPlay
GC.SuppressFinalize(this);
}
- /// <summary>
- /// Releases unmanaged and optionally managed resources.
- /// </summary>
- /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
- protected virtual void Dispose(bool disposing)
+ /// <inheritdoc />
+ public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken)
{
- if (_disposed)
+ if (session == null)
{
- return;
+ throw new InvalidOperationException("Session is null!");
}
- _sessionManager.SessionEnded -= OnSessionManagerSessionEnded;
- _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped;
-
- _disposed = true;
- }
-
- private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e)
- {
- var session = e.SessionInfo;
- if (!IsSessionInGroup(session))
+ if (request == null)
{
- return;
+ throw new InvalidOperationException("Request is null!");
}
- LeaveGroup(session, CancellationToken.None);
- }
-
- private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e)
- {
- var session = e.Session;
- if (!IsSessionInGroup(session))
+ // Locking required to access list of groups.
+ lock (_groupsLock)
{
- return;
- }
-
- LeaveGroup(session, CancellationToken.None);
- }
-
- private bool IsSessionInGroup(SessionInfo session)
- {
- return _sessionToGroupMap.ContainsKey(session.Id);
- }
-
- private bool HasAccessToItem(User user, Guid itemId)
- {
- var item = _libraryManager.GetItemById(itemId);
+ // Make sure that session has not joined another group.
+ if (_sessionToGroupMap.ContainsKey(session.Id))
+ {
+ var leaveGroupRequest = new LeaveGroupRequest();
+ LeaveGroup(session, leaveGroupRequest, cancellationToken);
+ }
- // Check ParentalRating access
- var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue
- || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating;
+ var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager);
+ _groups[group.GroupId] = group;
- if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess)
- {
- var collections = _libraryManager.GetCollectionFolders(item).Select(
- folder => folder.Id.ToString("N", CultureInfo.InvariantCulture));
+ if (!_sessionToGroupMap.TryAdd(session.Id, group))
+ {
+ throw new InvalidOperationException("Could not add session to group!");
+ }
- return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any();
+ group.CreateGroup(session, request, cancellationToken);
}
-
- return hasParentalRatingAccess;
- }
-
- private Guid? GetSessionGroup(SessionInfo session)
- {
- _sessionToGroupMap.TryGetValue(session.Id, out var group);
- return group?.GetGroupId();
}
/// <inheritdoc />
- public void NewGroup(SessionInfo session, CancellationToken cancellationToken)
+ public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
{
- var user = _userManager.GetUserById(session.UserId);
-
- if (user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups)
+ if (session == null)
{
- _logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id);
-
- var error = new GroupUpdate<string>
- {
- Type = GroupUpdateType.CreateGroupDenied
- };
-
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
+ throw new InvalidOperationException("Session is null!");
}
- lock (_groupsLock)
+ if (request == null)
{
- if (IsSessionInGroup(session))
- {
- LeaveGroup(session, cancellationToken);
- }
-
- var group = new SyncPlayController(_sessionManager, this);
- _groups[group.GetGroupId()] = group;
-
- group.CreateGroup(session, cancellationToken);
+ throw new InvalidOperationException("Request is null!");
}
- }
- /// <inheritdoc />
- public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken)
- {
var user = _userManager.GetUserById(session.UserId);
- if (user.SyncPlayAccess == SyncPlayAccess.None)
- {
- _logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id);
-
- var error = new GroupUpdate<string>()
- {
- Type = GroupUpdateType.JoinGroupDenied
- };
-
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
- }
-
+ // Locking required to access list of groups.
lock (_groupsLock)
{
- ISyncPlayController group;
- _groups.TryGetValue(groupId, out group);
+ _groups.TryGetValue(request.GroupId, out Group group);
if (group == null)
{
- _logger.LogWarning("JoinGroup: {0} tried to join group {0} that does not exist.", session.Id, groupId);
+ _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId);
- var error = new GroupUpdate<string>()
- {
- Type = GroupUpdateType.GroupDoesNotExist
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
return;
}
- if (!HasAccessToItem(user, group.GetPlayingItemId()))
+ // Group lock required to let other requests end first.
+ lock (group)
{
- _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId());
+ if (!group.HasAccessToPlayQueue(user))
+ {
+ _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access to some content of the playing queue.", session.Id, group.GroupId.ToString());
- var error = new GroupUpdate<string>()
+ var error = new GroupUpdate<string>(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return;
+ }
+
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var existingGroup))
{
- GroupId = group.GetGroupId().ToString(),
- Type = GroupUpdateType.LibraryAccessDenied
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
- }
+ if (existingGroup.GroupId.Equals(request.GroupId))
+ {
+ // Restore session.
+ group.SessionJoin(session, request, cancellationToken);
+ return;
+ }
+
+ var leaveGroupRequest = new LeaveGroupRequest();
+ LeaveGroup(session, leaveGroupRequest, cancellationToken);
+ }
- if (IsSessionInGroup(session))
- {
- if (GetSessionGroup(session).Equals(groupId))
+ if (!_sessionToGroupMap.TryAdd(session.Id, group))
{
- return;
+ throw new InvalidOperationException("Could not add session to group!");
}
- LeaveGroup(session, cancellationToken);
+ group.SessionJoin(session, request, cancellationToken);
}
-
- group.SessionJoin(session, request, cancellationToken);
}
}
/// <inheritdoc />
- public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken)
+ public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken)
{
- // TODO: determine what happens to users that are in a group and get their permissions revoked
- lock (_groupsLock)
+ if (session == null)
{
- _sessionToGroupMap.TryGetValue(session.Id, out var group);
+ throw new InvalidOperationException("Session is null!");
+ }
- if (group == null)
- {
- _logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id);
+ if (request == null)
+ {
+ throw new InvalidOperationException("Request is null!");
+ }
- var error = new GroupUpdate<string>()
+ // Locking required to access list of groups.
+ lock (_groupsLock)
+ {
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
+ {
+ // Group lock required to let other requests end first.
+ lock (group)
{
- Type = GroupUpdateType.NotInGroup
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
+ if (_sessionToGroupMap.TryRemove(session.Id, out var tempGroup))
+ {
+ if (!tempGroup.GroupId.Equals(group.GroupId))
+ {
+ throw new InvalidOperationException("Session was in wrong group!");
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException("Could not remove session from group!");
+ }
+
+ group.SessionLeave(session, request, cancellationToken);
+
+ if (group.IsGroupEmpty())
+ {
+ _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId);
+ _groups.Remove(group.GroupId, out _);
+ }
+ }
}
-
- group.SessionLeave(session, cancellationToken);
-
- if (group.IsGroupEmpty())
+ else
{
- _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId());
- _groups.Remove(group.GetGroupId(), out _);
+ _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
+
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
+ return;
}
}
}
/// <inheritdoc />
- public List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId)
+ public List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request)
{
- var user = _userManager.GetUserById(session.UserId);
-
- if (user.SyncPlayAccess == SyncPlayAccess.None)
+ if (session == null)
{
- return new List<GroupInfoView>();
+ throw new InvalidOperationException("Session is null!");
}
- // Filter by item if requested
- if (!filterItemId.Equals(Guid.Empty))
+ if (request == null)
{
- return _groups.Values.Where(
- group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())).Select(
- group => group.GetInfo()).ToList();
+ throw new InvalidOperationException("Request is null!");
}
- else
+
+ var user = _userManager.GetUserById(session.UserId);
+ List<GroupInfoDto> list = new List<GroupInfoDto>();
+
+ foreach (var group in _groups.Values)
{
- // Otherwise show all available groups
- return _groups.Values.Where(
- group => HasAccessToItem(user, group.GetPlayingItemId())).Select(
- group => group.GetInfo()).ToList();
+ // Locking required as group is not thread-safe.
+ lock (group)
+ {
+ if (group.HasAccessToPlayQueue(user))
+ {
+ list.Add(group.GetInfo());
+ }
+ }
}
+
+ return list;
}
/// <inheritdoc />
- public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
+ public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken)
{
- var user = _userManager.GetUserById(session.UserId);
-
- if (user.SyncPlayAccess == SyncPlayAccess.None)
+ if (session == null)
{
- _logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id);
-
- var error = new GroupUpdate<string>()
- {
- Type = GroupUpdateType.JoinGroupDenied
- };
-
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
+ throw new InvalidOperationException("Session is null!");
}
- lock (_groupsLock)
+ if (request == null)
{
- _sessionToGroupMap.TryGetValue(session.Id, out var group);
+ throw new InvalidOperationException("Request is null!");
+ }
- if (group == null)
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
+ {
+ // Group lock required as Group is not thread-safe.
+ lock (group)
{
- _logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id);
+ // Make sure that session still belongs to this group.
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId))
+ {
+ // Drop request.
+ return;
+ }
- var error = new GroupUpdate<string>()
+ // Drop request if group is empty.
+ if (group.IsGroupEmpty())
{
- Type = GroupUpdateType.NotInGroup
- };
- _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None);
- return;
+ return;
+ }
+
+ // Apply requested changes to group.
+ group.HandleRequest(session, request, cancellationToken);
}
+ }
+ else
+ {
+ _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id);
- group.HandleRequest(session, request, cancellationToken);
+ var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty);
+ _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None);
}
}
- /// <inheritdoc />
- public void AddSessionToGroup(SessionInfo session, ISyncPlayController group)
+ /// <summary>
+ /// Releases unmanaged and optionally managed resources.
+ /// </summary>
+ /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+ protected virtual void Dispose(bool disposing)
{
- if (IsSessionInGroup(session))
+ if (_disposed)
{
- throw new InvalidOperationException("Session in other group already!");
+ return;
}
- _sessionToGroupMap[session.Id] = group;
+ _sessionManager.SessionStarted -= OnSessionManagerSessionStarted;
+ _disposed = true;
}
- /// <inheritdoc />
- public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group)
+ private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e)
{
- if (!IsSessionInGroup(session))
- {
- throw new InvalidOperationException("Session not in any group!");
- }
+ var session = e.SessionInfo;
- _sessionToGroupMap.Remove(session.Id, out var tempGroup);
- if (!tempGroup.GetGroupId().Equals(group.GetGroupId()))
+ if (_sessionToGroupMap.TryGetValue(session.Id, out var group))
{
- throw new InvalidOperationException("Session was in wrong group!");
+ var request = new JoinGroupRequest(group.GroupId);
+ JoinGroup(session, request, CancellationToken.None);
}
}
}
diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs
index ccd1446dd..447c587f9 100644
--- a/Emby.Server.Implementations/TV/TVSeriesManager.cs
+++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs
@@ -56,13 +56,11 @@ namespace Emby.Server.Implementations.TV
return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, dtoOptions), request);
}
- var parentIdGuid = string.IsNullOrEmpty(request.ParentId) ? (Guid?)null : new Guid(request.ParentId);
-
BaseItem[] parents;
- if (parentIdGuid.HasValue)
+ if (request.ParentId.HasValue)
{
- var parent = _libraryManager.GetItemById(parentIdGuid.Value);
+ var parent = _libraryManager.GetItemById(request.ParentId.Value);
if (parent != null)
{
@@ -146,28 +144,10 @@ namespace Emby.Server.Implementations.TV
var allNextUp = seriesKeys
.Select(i => GetNextUp(i, currentUser, dtoOptions));
- // allNextUp = allNextUp.OrderByDescending(i => i.Item1);
-
- // If viewing all next up for all series, remove first episodes
- // But if that returns empty, keep those first episodes (avoid completely empty view)
- var alwaysEnableFirstEpisode = !string.IsNullOrEmpty(request.SeriesId);
- var anyFound = false;
-
return allNextUp
.Where(i =>
{
- if (alwaysEnableFirstEpisode || i.Item1 != DateTime.MinValue)
- {
- anyFound = true;
- return true;
- }
-
- if (!anyFound && i.Item1 == DateTime.MinValue)
- {
- return true;
- }
-
- return false;
+ return i.Item1 != DateTime.MinValue;
})
.Select(i => i.Item2())
.Where(i => i != null);
@@ -210,7 +190,7 @@ namespace Emby.Server.Implementations.TV
Func<Episode> getEpisode = () =>
{
- return _libraryManager.GetItemList(new InternalItemsQuery(user)
+ var nextEpisode = _libraryManager.GetItemList(new InternalItemsQuery(user)
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
@@ -223,6 +203,18 @@ namespace Emby.Server.Implementations.TV
MinSortName = lastWatchedEpisode?.SortName,
DtoOptions = dtoOptions
}).Cast<Episode>().FirstOrDefault();
+
+ if (nextEpisode != null)
+ {
+ var userData = _userDataManager.GetUserData(user, nextEpisode);
+
+ if (userData.PlaybackPositionTicks > 0)
+ {
+ return null;
+ }
+ }
+
+ return nextEpisode;
};
if (lastWatchedEpisode != null)
diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs
index b7a59cee2..4fd7ac0c1 100644
--- a/Emby.Server.Implementations/Udp/UdpServer.cs
+++ b/Emby.Server.Implementations/Udp/UdpServer.cs
@@ -49,7 +49,7 @@ namespace Emby.Server.Implementations.Udp
{
string localUrl = !string.IsNullOrEmpty(_config[AddressOverrideConfigKey])
? _config[AddressOverrideConfigKey]
- : await _appHost.GetLocalApiUrl(cancellationToken).ConfigureAwait(false);
+ : _appHost.GetSmartApiUrl(((IPEndPoint)endpoint).Address);
if (!string.IsNullOrEmpty(localUrl))
{
diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs
index 6b6b8c4fe..ef346dd5d 100644
--- a/Emby.Server.Implementations/Updates/InstallationManager.cs
+++ b/Emby.Server.Implementations/Updates/InstallationManager.cs
@@ -6,13 +6,15 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
-using System.Runtime.Serialization;
+using System.Net.Http.Json;
using System.Security.Cryptography;
+using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Common.Updates;
@@ -21,8 +23,6 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Updates;
using MediaBrowser.Model.IO;
-using MediaBrowser.Model.Net;
-using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.Updates;
using Microsoft.Extensions.Logging;
@@ -40,9 +40,9 @@ namespace Emby.Server.Implementations.Updates
private readonly IApplicationPaths _appPaths;
private readonly IEventManager _eventManager;
private readonly IHttpClientFactory _httpClientFactory;
- private readonly IJsonSerializer _jsonSerializer;
private readonly IServerConfigurationManager _config;
private readonly IFileSystem _fileSystem;
+ private readonly JsonSerializerOptions _jsonSerializerOptions;
/// <summary>
/// Gets the application host.
@@ -70,7 +70,6 @@ namespace Emby.Server.Implementations.Updates
IApplicationPaths appPaths,
IEventManager eventManager,
IHttpClientFactory httpClientFactory,
- IJsonSerializer jsonSerializer,
IServerConfigurationManager config,
IFileSystem fileSystem,
IZipClient zipClient)
@@ -83,33 +82,43 @@ namespace Emby.Server.Implementations.Updates
_appPaths = appPaths;
_eventManager = eventManager;
_httpClientFactory = httpClientFactory;
- _jsonSerializer = jsonSerializer;
_config = config;
_fileSystem = fileSystem;
_zipClient = zipClient;
+ _jsonSerializerOptions = JsonDefaults.GetOptions();
}
/// <inheritdoc />
public IEnumerable<InstallationInfo> CompletedInstallations => _completedInstallationsInternal;
/// <inheritdoc />
- public async Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default)
+ public async Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default)
{
try
{
- using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
- .GetAsync(manifest, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
-
- try
+ var packages = await _httpClientFactory.CreateClient(NamedClient.Default)
+ .GetFromJsonAsync<List<PackageInfo>>(new Uri(manifest), _jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
+ if (packages == null)
{
- return await _jsonSerializer.DeserializeFromStreamAsync<IReadOnlyList<PackageInfo>>(stream).ConfigureAwait(false);
+ return Array.Empty<PackageInfo>();
}
- catch (SerializationException ex)
+
+ // Store the repository and repository url with each version, as they may be spread apart.
+ foreach (var entry in packages)
{
- _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest);
- return Array.Empty<PackageInfo>();
+ foreach (var ver in entry.versions)
+ {
+ ver.repositoryName = manifestName;
+ ver.repositoryUrl = manifest;
+ }
}
+
+ return packages;
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Failed to deserialize the plugin manifest retrieved from {Manifest}", manifest);
+ return Array.Empty<PackageInfo>();
}
catch (UriFormatException ex)
{
@@ -123,17 +132,75 @@ namespace Emby.Server.Implementations.Updates
}
}
+ private static void MergeSort(IList<VersionInfo> source, IList<VersionInfo> dest)
+ {
+ int sLength = source.Count - 1;
+ int dLength = dest.Count;
+ int s = 0, d = 0;
+ var sourceVersion = source[0].VersionNumber;
+ var destVersion = dest[0].VersionNumber;
+
+ while (d < dLength)
+ {
+ if (sourceVersion.CompareTo(destVersion) >= 0)
+ {
+ if (s < sLength)
+ {
+ sourceVersion = source[++s].VersionNumber;
+ }
+ else
+ {
+ // Append all of destination to the end of source.
+ while (d < dLength)
+ {
+ source.Add(dest[d++]);
+ }
+
+ break;
+ }
+ }
+ else
+ {
+ source.Insert(s++, dest[d++]);
+ if (d >= dLength)
+ {
+ break;
+ }
+
+ sLength++;
+ destVersion = dest[d].VersionNumber;
+ }
+ }
+ }
+
/// <inheritdoc />
public async Task<IReadOnlyList<PackageInfo>> GetAvailablePackages(CancellationToken cancellationToken = default)
{
var result = new List<PackageInfo>();
foreach (RepositoryInfo repository in _config.Configuration.PluginRepositories)
{
- foreach (var package in await GetPackages(repository.Url, cancellationToken).ConfigureAwait(true))
+ if (repository.Enabled)
{
- package.repositoryName = repository.Name;
- package.repositoryUrl = repository.Url;
- result.Add(package);
+ // Where repositories have the same content, the details of the first is taken.
+ foreach (var package in await GetPackages(repository.Name, repository.Url, cancellationToken).ConfigureAwait(true))
+ {
+ if (!Guid.TryParse(package.guid, out var packageGuid))
+ {
+ // Package doesn't have a valid GUID, skip.
+ continue;
+ }
+
+ var existing = FilterPackages(result, package.name, packageGuid).FirstOrDefault();
+ if (existing != null)
+ {
+ // Assumption is both lists are ordered, so slot these into the correct place.
+ MergeSort(existing.versions, package.versions);
+ }
+ else
+ {
+ result.Add(package);
+ }
+ }
}
}
@@ -144,7 +211,8 @@ namespace Emby.Server.Implementations.Updates
public IEnumerable<PackageInfo> FilterPackages(
IEnumerable<PackageInfo> availablePackages,
string name = null,
- Guid guid = default)
+ Guid guid = default,
+ Version specificVersion = null)
{
if (name != null)
{
@@ -156,6 +224,11 @@ namespace Emby.Server.Implementations.Updates
availablePackages = availablePackages.Where(x => Guid.Parse(x.guid) == guid);
}
+ if (specificVersion != null)
+ {
+ availablePackages = availablePackages.Where(x => x.versions.Where(y => y.VersionNumber.Equals(specificVersion)).Any());
+ }
+
return availablePackages;
}
@@ -167,7 +240,7 @@ namespace Emby.Server.Implementations.Updates
Version minVersion = null,
Version specificVersion = null)
{
- var package = FilterPackages(availablePackages, name, guid).FirstOrDefault();
+ var package = FilterPackages(availablePackages, name, guid, specificVersion).FirstOrDefault();
// Package not found in repository
if (package == null)
@@ -181,21 +254,21 @@ namespace Emby.Server.Implementations.Updates
if (specificVersion != null)
{
- availableVersions = availableVersions.Where(x => new Version(x.version) == specificVersion);
+ availableVersions = availableVersions.Where(x => x.VersionNumber.Equals(specificVersion));
}
else if (minVersion != null)
{
- availableVersions = availableVersions.Where(x => new Version(x.version) >= minVersion);
+ availableVersions = availableVersions.Where(x => x.VersionNumber >= minVersion);
}
- foreach (var v in availableVersions.OrderByDescending(x => x.version))
+ foreach (var v in availableVersions.OrderByDescending(x => x.VersionNumber))
{
yield return new InstallationInfo
{
Changelog = v.changelog,
Guid = new Guid(package.guid),
Name = package.name,
- Version = new Version(v.version),
+ Version = v.VersionNumber,
SourceUrl = v.sourceUrl,
Checksum = v.checksum
};
@@ -241,7 +314,8 @@ namespace Emby.Server.Implementations.Updates
_currentInstallations.Add(tuple);
}
- var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token;
+ using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token);
+ var linkedToken = linkedTokenSource.Token;
await _eventManager.PublishAsync(new PluginInstallingEventArgs(package)).ConfigureAwait(false);
@@ -332,15 +406,15 @@ namespace Emby.Server.Implementations.Updates
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
- .GetAsync(package.SourceUrl, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ .GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
// CA5351: Do Not Use Broken Cryptographic Algorithms
#pragma warning disable CA5351
using var md5 = MD5.Create();
cancellationToken.ThrowIfCancellationRequested();
- var hash = Hex.Encode(md5.ComputeHash(stream));
+ var hash = Convert.ToHexString(md5.ComputeHash(stream));
if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
index 27a1f61be..c56233794 100644
--- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
+++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
@@ -18,6 +18,7 @@ namespace Jellyfin.Api.Auth
public class CustomAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly IAuthService _authService;
+ private readonly ILogger<CustomAuthenticationHandler> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CustomAuthenticationHandler" /> class.
@@ -35,6 +36,7 @@ namespace Jellyfin.Api.Auth
ISystemClock clock) : base(options, logger, encoder, clock)
{
_authService = authService;
+ _logger = logger.CreateLogger<CustomAuthenticationHandler>();
}
/// <inheritdoc />
@@ -70,7 +72,8 @@ namespace Jellyfin.Api.Auth
}
catch (AuthenticationException ex)
{
- return Task.FromResult(AuthenticateResult.Fail(ex));
+ _logger.LogDebug(ex, "Error authenticating with {Handler}", nameof(CustomAuthenticationHandler));
+ return Task.FromResult(AuthenticateResult.NoResult());
}
catch (SecurityException ex)
{
diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
new file mode 100644
index 000000000..b5932ea6b
--- /dev/null
+++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs
@@ -0,0 +1,58 @@
+using System.Threading.Tasks;
+using Jellyfin.Api.Helpers;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
+{
+ /// <summary>
+ /// Default authorization handler.
+ /// </summary>
+ public class SyncPlayAccessHandler : BaseAuthorizationHandler<SyncPlayAccessRequirement>
+ {
+ private readonly IUserManager _userManager;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SyncPlayAccessHandler"/> class.
+ /// </summary>
+ /// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
+ /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
+ /// <param name="httpContextAccessor">Instance of the <see cref="IHttpContextAccessor"/> interface.</param>
+ public SyncPlayAccessHandler(
+ IUserManager userManager,
+ INetworkManager networkManager,
+ IHttpContextAccessor httpContextAccessor)
+ : base(userManager, networkManager, httpContextAccessor)
+ {
+ _userManager = userManager;
+ }
+
+ /// <inheritdoc />
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement)
+ {
+ if (!ValidateClaims(context.User))
+ {
+ context.Fail();
+ return Task.CompletedTask;
+ }
+
+ var userId = ClaimHelpers.GetUserId(context.User);
+ var user = _userManager.GetUserById(userId!.Value);
+
+ if ((requirement.RequiredAccess.HasValue && user.SyncPlayAccess == requirement.RequiredAccess)
+ || user.SyncPlayAccess == SyncPlayAccess.CreateAndJoinGroups)
+ {
+ context.Succeed(requirement);
+ }
+ else
+ {
+ context.Fail();
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs
new file mode 100644
index 000000000..7fcaf69f6
--- /dev/null
+++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs
@@ -0,0 +1,33 @@
+using Jellyfin.Data.Enums;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy
+{
+ /// <summary>
+ /// The default authorization requirement.
+ /// </summary>
+ public class SyncPlayAccessRequirement : IAuthorizationRequirement
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
+ /// </summary>
+ /// <param name="requiredAccess">A value of <see cref="SyncPlayAccess"/>.</param>
+ public SyncPlayAccessRequirement(SyncPlayAccess requiredAccess)
+ {
+ RequiredAccess = requiredAccess;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SyncPlayAccessRequirement"/> class.
+ /// </summary>
+ public SyncPlayAccessRequirement()
+ {
+ RequiredAccess = null;
+ }
+
+ /// <summary>
+ /// Gets the required SyncPlay access.
+ /// </summary>
+ public SyncPlayAccess? RequiredAccess { get; }
+ }
+}
diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs
index 7d7767470..b35ceea1a 100644
--- a/Jellyfin.Api/Constants/Policies.cs
+++ b/Jellyfin.Api/Constants/Policies.cs
@@ -49,5 +49,15 @@ namespace Jellyfin.Api.Constants
/// Policy name for escaping schedule controls or requiring first time setup.
/// </summary>
public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl";
+
+ /// <summary>
+ /// Policy name for requiring access to SyncPlay.
+ /// </summary>
+ public const string SyncPlayAccess = "SyncPlayAccess";
+
+ /// <summary>
+ /// Policy name for requiring group creation access to SyncPlay.
+ /// </summary>
+ public const string SyncPlayCreateGroupAccess = "SyncPlayCreateGroupAccess";
}
}
diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs
index e8d6ccdf2..8c43d786a 100644
--- a/Jellyfin.Api/Controllers/ApiKeyController.cs
+++ b/Jellyfin.Api/Controllers/ApiKeyController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Jellyfin.Api.Constants;
diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs
index 9bad206e0..fed7ed3e5 100644
--- a/Jellyfin.Api/Controllers/ArtistsController.cs
+++ b/Jellyfin.Api/Controllers/ArtistsController.cs
@@ -1,9 +1,8 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
-using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Entities;
using MediaBrowser.Controller.Dto;
@@ -87,26 +86,26 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
- [FromQuery] string? mediaTypes,
- [FromQuery] string? genres,
- [FromQuery] string? genreIds,
- [FromQuery] string? officialRatings,
- [FromQuery] string? tags,
- [FromQuery] string? years,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person,
- [FromQuery] string? personIds,
- [FromQuery] string? personTypes,
- [FromQuery] string? studios,
- [FromQuery] string? studioIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
[FromQuery] Guid? userId,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
@@ -119,64 +118,55 @@ namespace Jellyfin.Api.Controllers
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
User? user = null;
- BaseItem parentItem;
+ BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
if (userId.HasValue && !userId.Equals(Guid.Empty))
{
user = _userManager.GetUserById(userId.Value);
- parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
}
- else
- {
- parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
- }
-
- var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
- var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
- var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = excludeItemTypesArr,
- IncludeItemTypes = includeItemTypesArr,
- MediaTypes = mediaTypesArr,
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
+ MediaTypes = mediaTypes,
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
- Tags = RequestHelpers.Split(tags, '|', true),
- OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
- Genres = RequestHelpers.Split(genres, '|', true),
- GenreIds = RequestHelpers.GetGuids(genreIds),
- StudioIds = RequestHelpers.GetGuids(studioIds),
+ Tags = tags,
+ OfficialRatings = officialRatings,
+ Genres = genres,
+ GenreIds = genreIds,
+ StudioIds = studioIds,
Person = person,
- PersonIds = RequestHelpers.GetGuids(personIds),
- PersonTypes = RequestHelpers.Split(personTypes, ',', true),
- Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+ PersonIds = personIds,
+ PersonTypes = personTypes,
+ Years = years,
MinCommunityRating = minCommunityRating,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
EnableTotalRecordCount = enableTotalRecordCount
};
- if (!string.IsNullOrWhiteSpace(parentId))
+ if (parentId.HasValue)
{
if (parentItem is Folder)
{
- query.AncestorIds = new[] { new Guid(parentId) };
+ query.AncestorIds = new[] { parentId.Value };
}
else
{
- query.ItemIds = new[] { new Guid(parentId) };
+ query.ItemIds = new[] { parentId.Value };
}
}
// Studios
- if (!string.IsNullOrEmpty(studios))
+ if (studios.Length != 0)
{
- query.StudioIds = studios.Split('|').Select(i =>
+ query.StudioIds = studios.Select(i =>
{
try
{
@@ -230,7 +220,7 @@ namespace Jellyfin.Api.Controllers
var (baseItem, itemCounts) = i;
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
- if (!string.IsNullOrWhiteSpace(includeItemTypes))
+ if (includeItemTypes.Length != 0)
{
dto.ChildCount = itemCounts.ItemCount;
dto.ProgramCount = itemCounts.ProgramCount;
@@ -295,26 +285,26 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
- [FromQuery] string? mediaTypes,
- [FromQuery] string? genres,
- [FromQuery] string? genreIds,
- [FromQuery] string? officialRatings,
- [FromQuery] string? tags,
- [FromQuery] string? years,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person,
- [FromQuery] string? personIds,
- [FromQuery] string? personTypes,
- [FromQuery] string? studios,
- [FromQuery] string? studioIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
[FromQuery] Guid? userId,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
@@ -327,64 +317,55 @@ namespace Jellyfin.Api.Controllers
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
User? user = null;
- BaseItem parentItem;
+ BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
if (userId.HasValue && !userId.Equals(Guid.Empty))
{
user = _userManager.GetUserById(userId.Value);
- parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
}
- else
- {
- parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
- }
-
- var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
- var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
- var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = excludeItemTypesArr,
- IncludeItemTypes = includeItemTypesArr,
- MediaTypes = mediaTypesArr,
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
+ MediaTypes = mediaTypes,
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
NameLessThan = nameLessThan,
NameStartsWith = nameStartsWith,
NameStartsWithOrGreater = nameStartsWithOrGreater,
- Tags = RequestHelpers.Split(tags, '|', true),
- OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
- Genres = RequestHelpers.Split(genres, '|', true),
- GenreIds = RequestHelpers.GetGuids(genreIds),
- StudioIds = RequestHelpers.GetGuids(studioIds),
+ Tags = tags,
+ OfficialRatings = officialRatings,
+ Genres = genres,
+ GenreIds = genreIds,
+ StudioIds = studioIds,
Person = person,
- PersonIds = RequestHelpers.GetGuids(personIds),
- PersonTypes = RequestHelpers.Split(personTypes, ',', true),
- Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+ PersonIds = personIds,
+ PersonTypes = personTypes,
+ Years = years,
MinCommunityRating = minCommunityRating,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
EnableTotalRecordCount = enableTotalRecordCount
};
- if (!string.IsNullOrWhiteSpace(parentId))
+ if (parentId.HasValue)
{
if (parentItem is Folder)
{
- query.AncestorIds = new[] { new Guid(parentId) };
+ query.AncestorIds = new[] { parentId.Value };
}
else
{
- query.ItemIds = new[] { new Guid(parentId) };
+ query.ItemIds = new[] { parentId.Value };
}
}
// Studios
- if (!string.IsNullOrEmpty(studios))
+ if (studios.Length != 0)
{
- query.StudioIds = studios.Split('|').Select(i =>
+ query.StudioIds = studios.Select(i =>
{
try
{
@@ -438,7 +419,7 @@ namespace Jellyfin.Api.Controllers
var (baseItem, itemCounts) = i;
var dto = _dtoService.GetItemByNameDto(baseItem, dtoOptions, null, user);
- if (!string.IsNullOrWhiteSpace(includeItemTypes))
+ if (includeItemTypes.Length != 0)
{
dto.ChildCount = itemCounts.ItemCount;
dto.ProgramCount = itemCounts.ProgramCount;
diff --git a/Jellyfin.Api/Controllers/AudioController.cs b/Jellyfin.Api/Controllers/AudioController.cs
index d4c6e4af9..616fe5b91 100644
--- a/Jellyfin.Api/Controllers/AudioController.cs
+++ b/Jellyfin.Api/Controllers/AudioController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
@@ -42,7 +42,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -71,29 +71,192 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
- /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
/// <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>
/// <response code="200">Audio stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
- [HttpGet("{itemId}/stream.{container:required}", Name = "GetAudioStreamByContainer")]
[HttpGet("{itemId}/stream", Name = "GetAudioStream")]
- [HttpHead("{itemId}/stream.{container:required}", Name = "HeadAudioStreamByContainer")]
[HttpHead("{itemId}/stream", Name = "HeadAudioStream")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesAudioFile]
public async Task<ActionResult> GetAudioStream(
[FromRoute, Required] Guid itemId,
- [FromRoute] string? container,
+ [FromQuery] string? container,
+ [FromQuery] bool? @static,
+ [FromQuery] string? @params,
+ [FromQuery] string? tag,
+ [FromQuery] string? deviceProfileId,
+ [FromQuery] string? playSessionId,
+ [FromQuery] string? segmentContainer,
+ [FromQuery] int? segmentLength,
+ [FromQuery] int? minSegments,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] string? deviceId,
+ [FromQuery] string? audioCodec,
+ [FromQuery] bool? enableAutoStreamCopy,
+ [FromQuery] bool? allowVideoStreamCopy,
+ [FromQuery] bool? allowAudioStreamCopy,
+ [FromQuery] bool? breakOnNonKeyFrames,
+ [FromQuery] int? audioSampleRate,
+ [FromQuery] int? maxAudioBitDepth,
+ [FromQuery] int? audioBitRate,
+ [FromQuery] int? audioChannels,
+ [FromQuery] int? maxAudioChannels,
+ [FromQuery] string? profile,
+ [FromQuery] string? level,
+ [FromQuery] float? framerate,
+ [FromQuery] float? maxFramerate,
+ [FromQuery] bool? copyTimestamps,
+ [FromQuery] long? startTimeTicks,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? videoBitRate,
+ [FromQuery] int? subtitleStreamIndex,
+ [FromQuery] SubtitleDeliveryMethod subtitleMethod,
+ [FromQuery] int? maxRefFrames,
+ [FromQuery] int? maxVideoBitDepth,
+ [FromQuery] bool? requireAvc,
+ [FromQuery] bool? deInterlace,
+ [FromQuery] bool? requireNonAnamorphic,
+ [FromQuery] int? transcodingMaxAudioChannels,
+ [FromQuery] int? cpuCoreLimit,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] bool? enableMpegtsM2TsMode,
+ [FromQuery] string? videoCodec,
+ [FromQuery] string? subtitleCodec,
+ [FromQuery] string? transcodeReasons,
+ [FromQuery] int? audioStreamIndex,
+ [FromQuery] int? videoStreamIndex,
+ [FromQuery] EncodingContext? context,
+ [FromQuery] Dictionary<string, string>? streamOptions)
+ {
+ StreamingRequestDto streamingRequest = new StreamingRequestDto
+ {
+ Id = itemId,
+ Container = container,
+ Static = @static ?? true,
+ Params = @params,
+ Tag = tag,
+ DeviceProfileId = deviceProfileId,
+ PlaySessionId = playSessionId,
+ SegmentContainer = segmentContainer,
+ SegmentLength = segmentLength,
+ MinSegments = minSegments,
+ MediaSourceId = mediaSourceId,
+ DeviceId = deviceId,
+ AudioCodec = audioCodec,
+ EnableAutoStreamCopy = enableAutoStreamCopy ?? true,
+ AllowAudioStreamCopy = allowAudioStreamCopy ?? true,
+ AllowVideoStreamCopy = allowVideoStreamCopy ?? true,
+ BreakOnNonKeyFrames = breakOnNonKeyFrames ?? false,
+ AudioSampleRate = audioSampleRate,
+ MaxAudioChannels = maxAudioChannels,
+ AudioBitRate = audioBitRate,
+ MaxAudioBitDepth = maxAudioBitDepth,
+ AudioChannels = audioChannels,
+ Profile = profile,
+ Level = level,
+ Framerate = framerate,
+ MaxFramerate = maxFramerate,
+ CopyTimestamps = copyTimestamps ?? true,
+ StartTimeTicks = startTimeTicks,
+ Width = width,
+ Height = height,
+ VideoBitRate = videoBitRate,
+ SubtitleStreamIndex = subtitleStreamIndex,
+ SubtitleMethod = subtitleMethod,
+ MaxRefFrames = maxRefFrames,
+ MaxVideoBitDepth = maxVideoBitDepth,
+ RequireAvc = requireAvc ?? true,
+ DeInterlace = deInterlace ?? true,
+ RequireNonAnamorphic = requireNonAnamorphic ?? true,
+ TranscodingMaxAudioChannels = transcodingMaxAudioChannels,
+ CpuCoreLimit = cpuCoreLimit,
+ LiveStreamId = liveStreamId,
+ EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
+ VideoCodec = videoCodec,
+ SubtitleCodec = subtitleCodec,
+ TranscodeReasons = transcodeReasons,
+ AudioStreamIndex = audioStreamIndex,
+ VideoStreamIndex = videoStreamIndex,
+ Context = context ?? EncodingContext.Static,
+ StreamOptions = streamOptions
+ };
+
+ return await _audioHelper.GetAudioStream(_transcodingJobType, streamingRequest).ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets an audio stream.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="container">The audio container.</param>
+ /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+ /// <param name="params">The streaming parameters.</param>
+ /// <param name="tag">The tag.</param>
+ /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <param name="segmentContainer">The segment container.</param>
+ /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="minSegments">The minimum number of segments.</param>
+ /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+ /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+ /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+ /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+ /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+ /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+ /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+ /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+ /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+ /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+ /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+ /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+ /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+ /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+ /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+ /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+ /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+ /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+ /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+ /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+ /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+ /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+ /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+ /// <param name="maxRefFrames">Optional.</param>
+ /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+ /// <param name="requireAvc">Optional. Whether to require avc.</param>
+ /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+ /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+ /// <param name="liveStreamId">The live stream id.</param>
+ /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+ /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+ /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+ /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+ /// <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>
+ /// <response code="200">Audio stream returned.</response>
+ /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+ [HttpGet("{itemId}/stream.{container}", Name = "GetAudioStreamByContainer")]
+ [HttpHead("{itemId}/stream.{container}", Name = "HeadAudioStreamByContainer")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesAudioFile]
+ public async Task<ActionResult> GetAudioStreamByContainer(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -136,7 +299,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] string? videoCodec,
[FromQuery] string? subtitleCodec,
- [FromQuery] string? transcodingReasons,
+ [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext? context,
@@ -188,7 +351,7 @@ namespace Jellyfin.Api.Controllers
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
- TranscodeReasons = transcodingReasons,
+ TranscodeReasons = transcodeReasons,
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context ?? EncodingContext.Static,
diff --git a/Jellyfin.Api/Controllers/BrandingController.cs b/Jellyfin.Api/Controllers/BrandingController.cs
index 1d4836f27..d3ea41201 100644
--- a/Jellyfin.Api/Controllers/BrandingController.cs
+++ b/Jellyfin.Api/Controllers/BrandingController.cs
@@ -1,4 +1,4 @@
-using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Branding;
using Microsoft.AspNetCore.Http;
diff --git a/Jellyfin.Api/Controllers/ChannelsController.cs b/Jellyfin.Api/Controllers/ChannelsController.cs
index ec9d7cdce..b70c76e80 100644
--- a/Jellyfin.Api/Controllers/ChannelsController.cs
+++ b/Jellyfin.Api/Controllers/ChannelsController.cs
@@ -92,7 +92,7 @@ namespace Jellyfin.Api.Controllers
/// <response code="200">Channel features returned.</response>
/// <returns>An <see cref="OkResult"/> containing the channel features.</returns>
[HttpGet("{channelId}/Features")]
- public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] string channelId)
+ public ActionResult<ChannelFeatures> GetChannelFeatures([FromRoute, Required] Guid channelId)
{
return _channelManager.GetChannelFeatures(channelId);
}
@@ -198,7 +198,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? channelIds)
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds)
{
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
@@ -208,11 +208,7 @@ namespace Jellyfin.Api.Controllers
{
Limit = limit,
StartIndex = startIndex,
- ChannelIds = (channelIds ?? string.Empty)
- .Split(',')
- .Where(i => !string.IsNullOrWhiteSpace(i))
- .Select(i => new Guid(i))
- .ToArray(),
+ ChannelIds = channelIds,
DtoOptions = new DtoOptions { Fields = fields }
};
diff --git a/Jellyfin.Api/Controllers/CollectionController.cs b/Jellyfin.Api/Controllers/CollectionController.cs
index eae06b767..2a7b2b5c6 100644
--- a/Jellyfin.Api/Controllers/CollectionController.cs
+++ b/Jellyfin.Api/Controllers/CollectionController.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
using MediaBrowser.Controller.Collections;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Net;
@@ -54,7 +55,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<CollectionCreationResult>> CreateCollection(
[FromQuery] string? name,
- [FromQuery] string? ids,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] ids,
[FromQuery] Guid? parentId,
[FromQuery] bool isLocked = false)
{
@@ -65,7 +66,7 @@ namespace Jellyfin.Api.Controllers
IsLocked = isLocked,
Name = name,
ParentId = parentId,
- ItemIdList = RequestHelpers.Split(ids, ',', true),
+ ItemIdList = ids,
UserIds = new[] { userId }
}).ConfigureAwait(false);
@@ -88,9 +89,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task<ActionResult> AddToCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
+ public async Task<ActionResult> AddToCollection(
+ [FromRoute, Required] Guid collectionId,
+ [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{
- await _collectionManager.AddToCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(true);
+ await _collectionManager.AddToCollectionAsync(collectionId, ids).ConfigureAwait(true);
return NoContent();
}
@@ -103,9 +106,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpDelete("{collectionId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task<ActionResult> RemoveFromCollection([FromRoute, Required] Guid collectionId, [FromQuery, Required] string ids)
+ public async Task<ActionResult> RemoveFromCollection(
+ [FromRoute, Required] Guid collectionId,
+ [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{
- await _collectionManager.RemoveFromCollectionAsync(collectionId, RequestHelpers.GetGuids(ids)).ConfigureAwait(false);
+ await _collectionManager.RemoveFromCollectionAsync(collectionId, ids).ConfigureAwait(false);
return NoContent();
}
}
diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs
index a859ac114..ccc81dfc5 100644
--- a/Jellyfin.Api/Controllers/DashboardController.cs
+++ b/Jellyfin.Api/Controllers/DashboardController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
index 76f5717e3..8b8f63015 100644
--- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
+++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs
@@ -6,6 +6,7 @@ using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authorization;
@@ -47,13 +48,19 @@ namespace Jellyfin.Api.Controllers
[FromQuery, Required] Guid userId,
[FromQuery, Required] string client)
{
- var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client);
- var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client);
+ if (!Guid.TryParse(displayPreferencesId, out var itemId))
+ {
+ itemId = displayPreferencesId.GetMD5();
+ }
+
+ var displayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
+ var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
+ itemPreferences.ItemId = itemId;
var dto = new DisplayPreferencesDto
{
Client = displayPreferences.Client,
- Id = displayPreferences.UserId.ToString(),
+ Id = displayPreferences.ItemId.ToString(),
ViewType = itemPreferences.ViewType.ToString(),
SortBy = itemPreferences.SortBy,
SortOrder = itemPreferences.SortOrder,
@@ -81,6 +88,16 @@ namespace Jellyfin.Api.Controllers
dto.CustomPrefs["enableNextVideoInfoOverlay"] = displayPreferences.EnableNextVideoInfoOverlay.ToString(CultureInfo.InvariantCulture);
dto.CustomPrefs["tvhome"] = displayPreferences.TvHome;
+ // Load all custom display preferences
+ var customDisplayPreferences = _displayPreferencesManager.ListCustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client);
+ if (customDisplayPreferences != null)
+ {
+ foreach (var (key, value) in customDisplayPreferences)
+ {
+ dto.CustomPrefs.TryAdd(key, value);
+ }
+ }
+
// This will essentially be a noop if no changes have been made, but new prefs must be saved at least.
_displayPreferencesManager.SaveChanges();
@@ -115,7 +132,12 @@ namespace Jellyfin.Api.Controllers
HomeSectionType.LatestMedia, HomeSectionType.None,
};
- var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, client);
+ if (!Guid.TryParse(displayPreferencesId, out var itemId))
+ {
+ itemId = displayPreferencesId.GetMD5();
+ }
+
+ var existingDisplayPreferences = _displayPreferencesManager.GetDisplayPreferences(userId, itemId, client);
existingDisplayPreferences.IndexBy = Enum.TryParse<IndexingKind>(displayPreferences.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null;
existingDisplayPreferences.ShowBackdrop = displayPreferences.ShowBackdrop;
existingDisplayPreferences.ShowSidebar = displayPreferences.ShowSidebar;
@@ -124,21 +146,33 @@ namespace Jellyfin.Api.Controllers
existingDisplayPreferences.ChromecastVersion = displayPreferences.CustomPrefs.TryGetValue("chromecastVersion", out var chromecastVersion)
? Enum.Parse<ChromecastVersion>(chromecastVersion, true)
: ChromecastVersion.Stable;
+ displayPreferences.CustomPrefs.Remove("chromecastVersion");
+
existingDisplayPreferences.EnableNextVideoInfoOverlay = displayPreferences.CustomPrefs.TryGetValue("enableNextVideoInfoOverlay", out var enableNextVideoInfoOverlay)
? bool.Parse(enableNextVideoInfoOverlay)
: true;
+ displayPreferences.CustomPrefs.Remove("enableNextVideoInfoOverlay");
+
existingDisplayPreferences.SkipBackwardLength = displayPreferences.CustomPrefs.TryGetValue("skipBackLength", out var skipBackLength)
? int.Parse(skipBackLength, CultureInfo.InvariantCulture)
: 10000;
+ displayPreferences.CustomPrefs.Remove("skipBackLength");
+
existingDisplayPreferences.SkipForwardLength = displayPreferences.CustomPrefs.TryGetValue("skipForwardLength", out var skipForwardLength)
? int.Parse(skipForwardLength, CultureInfo.InvariantCulture)
: 30000;
+ displayPreferences.CustomPrefs.Remove("skipForwardLength");
+
existingDisplayPreferences.DashboardTheme = displayPreferences.CustomPrefs.TryGetValue("dashboardTheme", out var theme)
? theme
: string.Empty;
+ displayPreferences.CustomPrefs.Remove("dashboardTheme");
+
existingDisplayPreferences.TvHome = displayPreferences.CustomPrefs.TryGetValue("tvhome", out var home)
? home
: string.Empty;
+ displayPreferences.CustomPrefs.Remove("tvhome");
+
existingDisplayPreferences.HomeSections.Clear();
foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase)))
@@ -149,26 +183,34 @@ namespace Jellyfin.Api.Controllers
type = order < 7 ? defaults[order] : HomeSectionType.None;
}
+ displayPreferences.CustomPrefs.Remove(key);
existingDisplayPreferences.HomeSections.Add(new HomeSection { Order = order, Type = type });
}
foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.OrdinalIgnoreCase)))
{
- var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Parse(key.Substring("landing-".Length)), existingDisplayPreferences.Client);
- itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType);
+ if (Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var preferenceId))
+ {
+ var itemPreferences = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, preferenceId, existingDisplayPreferences.Client);
+ itemPreferences.ViewType = Enum.Parse<ViewType>(displayPreferences.ViewType);
+ displayPreferences.CustomPrefs.Remove(key);
+ }
}
- var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, Guid.Empty, existingDisplayPreferences.Client);
+ var itemPrefs = _displayPreferencesManager.GetItemDisplayPreferences(existingDisplayPreferences.UserId, itemId, existingDisplayPreferences.Client);
itemPrefs.SortBy = displayPreferences.SortBy;
itemPrefs.SortOrder = displayPreferences.SortOrder;
itemPrefs.RememberIndexing = displayPreferences.RememberIndexing;
itemPrefs.RememberSorting = displayPreferences.RememberSorting;
+ itemPrefs.ItemId = itemId;
if (Enum.TryParse<ViewType>(displayPreferences.ViewType, true, out var viewType))
{
itemPrefs.ViewType = viewType;
}
+ // Set all remaining custom preferences.
+ _displayPreferencesManager.SetCustomItemDisplayPreferences(userId, itemId, existingDisplayPreferences.Client, displayPreferences.CustomPrefs);
_displayPreferencesManager.SaveChanges();
return NoContent();
diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs
index 4e6455eaa..4fd9c2fbf 100644
--- a/Jellyfin.Api/Controllers/DlnaServerController.cs
+++ b/Jellyfin.Api/Controllers/DlnaServerController.cs
@@ -252,7 +252,7 @@ namespace Jellyfin.Api.Controllers
private string GetAbsoluteUri()
{
- return $"{Request.Scheme}://{Request.Host}{Request.Path}";
+ return $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}";
}
private Task<ControlResponse> ProcessControlRequestInternalAsync(string id, Stream requestStream, IUpnpService service)
diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs
index 6e59da798..6e85737d2 100644
--- a/Jellyfin.Api/Controllers/DynamicHlsController.cs
+++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs
@@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -41,6 +42,9 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
public class DynamicHlsController : BaseJellyfinApiController
{
+ private const string DefaultEncoderPreset = "veryfast";
+ private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
+
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IDlnaManager _dlnaManager;
@@ -56,8 +60,7 @@ namespace Jellyfin.Api.Controllers
private readonly ILogger<DynamicHlsController> _logger;
private readonly EncodingHelper _encodingHelper;
private readonly DynamicHlsHelper _dynamicHlsHelper;
-
- private readonly TranscodingJobType _transcodingJobType = TranscodingJobType.Hls;
+ private readonly EncodingOptions _encodingOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
@@ -92,6 +95,8 @@ namespace Jellyfin.Api.Controllers
ILogger<DynamicHlsController> logger,
DynamicHlsHelper dynamicHlsHelper)
{
+ _encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
+
_libraryManager = libraryManager;
_userManager = userManager;
_dlnaManager = dlnaManager;
@@ -106,8 +111,7 @@ namespace Jellyfin.Api.Controllers
_transcodingJobHelper = transcodingJobHelper;
_logger = logger;
_dynamicHlsHelper = dynamicHlsHelper;
-
- _encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+ _encodingOptions = serverConfigurationManager.GetEncodingOptions();
}
/// <summary>
@@ -120,7 +124,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -149,14 +153,14 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
- /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
/// <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>
@@ -212,7 +216,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] string? videoCodec,
[FromQuery] string? subtitleCodec,
- [FromQuery] string? transcodingReasons,
+ [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext context,
@@ -264,7 +268,7 @@ namespace Jellyfin.Api.Controllers
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
- TranscodeReasons = transcodingReasons,
+ TranscodeReasons = transcodeReasons,
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context,
@@ -272,7 +276,7 @@ namespace Jellyfin.Api.Controllers
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
};
- return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+ return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
}
/// <summary>
@@ -285,7 +289,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -315,14 +319,14 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
- /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
/// <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>
@@ -379,7 +383,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] string? videoCodec,
[FromQuery] string? subtitleCodec,
- [FromQuery] string? transcodingReasons,
+ [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext context,
@@ -431,7 +435,7 @@ namespace Jellyfin.Api.Controllers
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
- TranscodeReasons = transcodingReasons,
+ TranscodeReasons = transcodeReasons,
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context,
@@ -439,7 +443,7 @@ namespace Jellyfin.Api.Controllers
EnableAdaptiveBitrateStreaming = enableAdaptiveBitrateStreaming
};
- return await _dynamicHlsHelper.GetMasterHlsPlaylist(_transcodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
+ return await _dynamicHlsHelper.GetMasterHlsPlaylist(TranscodingJobType, streamingRequest, enableAdaptiveBitrateStreaming).ConfigureAwait(false);
}
/// <summary>
@@ -452,7 +456,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -481,14 +485,14 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
- /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
/// <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>
@@ -542,7 +546,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] string? videoCodec,
[FromQuery] string? subtitleCodec,
- [FromQuery] string? transcodingReasons,
+ [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext context,
@@ -594,7 +598,7 @@ namespace Jellyfin.Api.Controllers
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
- TranscodeReasons = transcodingReasons,
+ TranscodeReasons = transcodeReasons,
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context,
@@ -615,7 +619,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -645,14 +649,14 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
- /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
/// <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>
@@ -707,7 +711,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] string? videoCodec,
[FromQuery] string? subtitleCodec,
- [FromQuery] string? transcodingReasons,
+ [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext context,
@@ -759,7 +763,7 @@ namespace Jellyfin.Api.Controllers
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
- TranscodeReasons = transcodingReasons,
+ TranscodeReasons = transcodeReasons,
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context,
@@ -812,14 +816,14 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
- /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
/// <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>
@@ -834,7 +838,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId,
- [FromRoute] string container,
+ [FromRoute, Required] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -877,7 +881,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] string? videoCodec,
[FromQuery] string? subtitleCodec,
- [FromQuery] string? transcodingReasons,
+ [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext context,
@@ -929,7 +933,7 @@ namespace Jellyfin.Api.Controllers
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
- TranscodeReasons = transcodingReasons,
+ TranscodeReasons = transcodeReasons,
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context,
@@ -953,7 +957,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -983,14 +987,14 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
- /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
/// <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>
@@ -1005,7 +1009,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId,
- [FromRoute] string container,
+ [FromRoute, Required] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -1049,7 +1053,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] string? videoCodec,
[FromQuery] string? subtitleCodec,
- [FromQuery] string? transcodingReasons,
+ [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext context,
@@ -1101,7 +1105,7 @@ namespace Jellyfin.Api.Controllers
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
- TranscodeReasons = transcodingReasons,
+ TranscodeReasons = transcodeReasons,
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context,
@@ -1129,7 +1133,7 @@ namespace Jellyfin.Api.Controllers
_dlnaManager,
_deviceManager,
_transcodingJobHelper,
- _transcodingJobType,
+ TranscodingJobType,
cancellationTokenSource.Token)
.ConfigureAwait(false);
@@ -1137,11 +1141,19 @@ namespace Jellyfin.Api.Controllers
var segmentLengths = GetSegmentLengths(state);
+ var segmentContainer = state.Request.SegmentContainer ?? "ts";
+
+ // http://ffmpeg.org/ffmpeg-all.html#toc-hls-2
+ var isHlsInFmp4 = string.Equals(segmentContainer, "mp4", StringComparison.OrdinalIgnoreCase);
+ var hlsVersion = isHlsInFmp4 ? "7" : "3";
+
var builder = new StringBuilder();
builder.AppendLine("#EXTM3U")
.AppendLine("#EXT-X-PLAYLIST-TYPE:VOD")
- .AppendLine("#EXT-X-VERSION:3")
+ .Append("#EXT-X-VERSION:")
+ .Append(hlsVersion)
+ .AppendLine()
.Append("#EXT-X-TARGETDURATION:")
.Append(Math.Ceiling(segmentLengths.Length > 0 ? segmentLengths.Max() : state.SegmentLength))
.AppendLine()
@@ -1151,6 +1163,18 @@ namespace Jellyfin.Api.Controllers
var segmentExtension = GetSegmentFileExtension(streamingRequest.SegmentContainer);
var queryString = Request.QueryString;
+ if (isHlsInFmp4)
+ {
+ builder.Append("#EXT-X-MAP:URI=\"")
+ .Append("hls1/")
+ .Append(name)
+ .Append("/-1")
+ .Append(segmentExtension)
+ .Append(queryString)
+ .Append('"')
+ .AppendLine();
+ }
+
foreach (var length in segmentLengths)
{
builder.Append("#EXTINF:")
@@ -1194,7 +1218,7 @@ namespace Jellyfin.Api.Controllers
_dlnaManager,
_deviceManager,
_transcodingJobHelper,
- _transcodingJobType,
+ TranscodingJobType,
cancellationTokenSource.Token)
.ConfigureAwait(false);
@@ -1208,7 +1232,7 @@ namespace Jellyfin.Api.Controllers
if (System.IO.File.Exists(segmentPath))
{
- job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
+ job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
_logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
}
@@ -1222,7 +1246,7 @@ namespace Jellyfin.Api.Controllers
{
if (System.IO.File.Exists(segmentPath))
{
- job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
+ job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
transcodingLock.Release();
released = true;
_logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
@@ -1233,7 +1257,13 @@ namespace Jellyfin.Api.Controllers
var currentTranscodingIndex = GetCurrentTranscodingIndex(playlistPath, segmentExtension);
var segmentGapRequiringTranscodingChange = 24 / state.SegmentLength;
- if (currentTranscodingIndex == null)
+ if (segmentId == -1)
+ {
+ _logger.LogDebug("Starting transcoding because fmp4 init file is being requested");
+ startTranscoding = true;
+ segmentId = 0;
+ }
+ else if (currentTranscodingIndex == null)
{
_logger.LogDebug("Starting transcoding because currentTranscodingIndex=null");
startTranscoding = true;
@@ -1265,13 +1295,12 @@ namespace Jellyfin.Api.Controllers
streamingRequest.StartTimeTicks = GetStartPositionTicks(state, segmentId);
state.WaitForPath = segmentPath;
- var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
job = await _transcodingJobHelper.StartFfMpeg(
state,
playlistPath,
- GetCommandLineArguments(playlistPath, encodingOptions, state, true, segmentId),
+ GetCommandLineArguments(playlistPath, state, true, segmentId),
Request,
- _transcodingJobType,
+ TranscodingJobType,
cancellationTokenSource).ConfigureAwait(false);
}
catch
@@ -1284,7 +1313,7 @@ namespace Jellyfin.Api.Controllers
}
else
{
- job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
+ job = _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
if (job?.TranscodingThrottler != null)
{
await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
@@ -1301,7 +1330,7 @@ namespace Jellyfin.Api.Controllers
}
_logger.LogDebug("returning {0} [general case]", segmentPath);
- job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, _transcodingJobType);
+ job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
}
@@ -1325,11 +1354,10 @@ namespace Jellyfin.Api.Controllers
return result.ToArray();
}
- private string GetCommandLineArguments(string outputPath, EncodingOptions encodingOptions, StreamState state, bool isEncoding, int startNumber)
+ private string GetCommandLineArguments(string outputPath, StreamState state, bool isEncoding, int startNumber)
{
- var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
-
- var threads = _encodingHelper.GetNumberOfThreads(state, encodingOptions, videoCodec);
+ var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+ var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
if (state.BaseRequest.BreakOnNonKeyFrames)
{
@@ -1341,36 +1369,57 @@ namespace Jellyfin.Api.Controllers
state.BaseRequest.BreakOnNonKeyFrames = false;
}
- var inputModifier = _encodingHelper.GetInputModifier(state, encodingOptions);
-
// If isEncoding is true we're actually starting ffmpeg
var startNumberParam = isEncoding ? startNumber.ToString(CultureInfo.InvariantCulture) : "0";
-
+ var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+ var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+ var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+ var outputExtension = GetSegmentFileExtension(state.Request.SegmentContainer);
+ var outputTsArg = outputPrefix + "%d" + outputExtension;
- var outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + GetSegmentFileExtension(state.Request.SegmentContainer);
-
- var segmentFormat = GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
+ var segmentFormat = outputExtension.TrimStart('.');
if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
{
segmentFormat = "mpegts";
}
+ else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
+ {
+ var outputFmp4HeaderArg = string.Empty;
+ var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ if (isWindows)
+ {
+ // on Windows, the path of fmp4 header file needs to be configured
+ outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"";
+ }
+ else
+ {
+ // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
+ outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"";
+ }
+
+ segmentFormat = "fmp4" + outputFmp4HeaderArg;
+ }
+ else
+ {
+ _logger.LogError("Invalid HLS segment container: " + segmentFormat);
+ }
- var maxMuxingQueueSize = encodingOptions.MaxMuxingQueueSize > 128
- ? encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
+ var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
+ ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
: "128";
return string.Format(
CultureInfo.InvariantCulture,
- "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -individual_header_trailer 0 -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"",
+ "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number {9} -hls_segment_filename \"{10}\" -hls_playlist_type vod -hls_list_size 0 -y \"{11}\"",
inputModifier,
- _encodingHelper.GetInputArgument(state, encodingOptions),
+ _encodingHelper.GetInputArgument(state, _encodingOptions),
threads,
mapArgs,
- GetVideoArguments(state, encodingOptions, startNumber),
- GetAudioArguments(state, encodingOptions),
+ GetVideoArguments(state, startNumber),
+ GetAudioArguments(state),
maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
segmentFormat,
@@ -1379,50 +1428,63 @@ namespace Jellyfin.Api.Controllers
outputPath).Trim();
}
- private string GetAudioArguments(StreamState state, EncodingOptions encodingOptions)
+ /// <summary>
+ /// Gets the audio arguments for transcoding.
+ /// </summary>
+ /// <param name="state">The <see cref="StreamState"/>.</param>
+ /// <returns>The command line arguments for audio transcoding.</returns>
+ private string GetAudioArguments(StreamState state)
{
+ if (state.AudioStream == null)
+ {
+ return string.Empty;
+ }
+
var audioCodec = _encodingHelper.GetAudioEncoder(state);
if (!state.IsOutputVideo)
{
if (EncodingHelper.IsCopyCodec(audioCodec))
{
- return "-acodec copy";
+ var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+
+ return "-acodec copy -strict -2" + bitStreamArgs;
}
- var audioTranscodeParams = new List<string>();
+ var audioTranscodeParams = string.Empty;
- audioTranscodeParams.Add("-acodec " + audioCodec);
+ audioTranscodeParams += "-acodec " + audioCodec;
if (state.OutputAudioBitrate.HasValue)
{
- audioTranscodeParams.Add("-ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture));
+ audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
}
if (state.OutputAudioChannels.HasValue)
{
- audioTranscodeParams.Add("-ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture));
+ audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
}
if (state.OutputAudioSampleRate.HasValue)
{
- audioTranscodeParams.Add("-ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture));
+ audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
}
- audioTranscodeParams.Add("-vn");
- return string.Join(' ', audioTranscodeParams);
+ audioTranscodeParams += " -vn";
+ return audioTranscodeParams;
}
if (EncodingHelper.IsCopyCodec(audioCodec))
{
- var videoCodec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+ var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
+ var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec))
{
- return "-codec:a:0 copy -copypriorss:a:0 0";
+ return "-codec:a:0 copy -strict -2 -copypriorss:a:0 0" + bitStreamArgs;
}
- return "-codec:a:0 copy";
+ return "-codec:a:0 copy -strict -2" + bitStreamArgs;
}
var args = "-codec:a:0 " + audioCodec;
@@ -1446,94 +1508,89 @@ namespace Jellyfin.Api.Controllers
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
}
- args += " " + _encodingHelper.GetAudioFilterParam(state, encodingOptions, true);
+ args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
return args;
}
- private string GetVideoArguments(StreamState state, EncodingOptions encodingOptions, int startNumber)
+ /// <summary>
+ /// Gets the video arguments for transcoding.
+ /// </summary>
+ /// <param name="state">The <see cref="StreamState"/>.</param>
+ /// <param name="startNumber">The first number in the hls sequence.</param>
+ /// <returns>The command line arguments for video transcoding.</returns>
+ private string GetVideoArguments(StreamState state, int startNumber)
{
+ if (state.VideoStream == null)
+ {
+ return string.Empty;
+ }
+
if (!state.IsOutputVideo)
{
return string.Empty;
}
- var codec = _encodingHelper.GetVideoEncoder(state, encodingOptions);
+ var codec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var args = "-codec:v:0 " + codec;
+ // Prefer hvc1 to hev1.
+ if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ args += " -tag:v:0 hvc1";
+ }
+
// if (state.EnableMpegtsM2TsMode)
// {
// args += " -mpegts_m2ts_mode 1";
// }
- // See if we can save come cpu cycles by avoiding encoding
+ // See if we can save come cpu cycles by avoiding encoding.
if (EncodingHelper.IsCopyCodec(codec))
{
if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
{
- string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
+ string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
if (!string.IsNullOrEmpty(bitStreamArgs))
{
args += " " + bitStreamArgs;
}
}
+ args += " -start_at_zero";
+
// args += " -flags -global_header";
}
else
{
- var gopArg = string.Empty;
- var keyFrameArg = string.Format(
- CultureInfo.InvariantCulture,
- " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"",
- startNumber * state.SegmentLength,
- state.SegmentLength);
+ args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
- var framerate = state.VideoStream?.RealFrameRate;
+ // Set the key frame params for video encoding to match the hls segment time.
+ args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, false, startNumber);
- if (framerate.HasValue)
+ // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
+ if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
{
- // This is to make sure keyframe interval is limited to our segment,
- // as forcing keyframes is not enough.
- // Example: we encoded half of desired length, then codec detected
- // scene cut and inserted a keyframe; next forced keyframe would
- // be created outside of segment, which breaks seeking
- // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe
- gopArg = string.Format(
- CultureInfo.InvariantCulture,
- " -g {0} -keyint_min {0} -sc_threshold 0",
- Math.Ceiling(state.SegmentLength * framerate.Value));
- }
-
- args += " " + _encodingHelper.GetVideoQualityParam(state, codec, encodingOptions, "veryfast");
-
- // Unable to force key frames using these hw encoders, set key frames by GOP
- if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
- || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase))
- {
- args += " " + gopArg;
- }
- else
- {
- args += " " + keyFrameArg + gopArg;
+ args += " -bf 0";
}
// args += " -mixed-refs 0 -refs 3 -x264opts b_pyramid=0:weightb=0:weightp=0";
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
- // This is for graphical subs
if (hasGraphicalSubs)
{
- args += _encodingHelper.GetGraphicalSubtitleParam(state, encodingOptions, codec);
+ // Graphical subs overlay and resolution params.
+ args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
}
-
- // Add resolution params, if specified
else
{
- args += _encodingHelper.GetOutputSizeParam(state, encodingOptions, codec);
+ // Resolution params.
+ args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
}
// -start_at_zero is necessary to use with -ss when seeking,
@@ -1693,7 +1750,7 @@ namespace Jellyfin.Api.Controllers
private int? GetCurrentTranscodingIndex(string playlist, string segmentExtension)
{
- var job = _transcodingJobHelper.GetTranscodingJob(playlist, _transcodingJobType);
+ var job = _transcodingJobHelper.GetTranscodingJob(playlist, TranscodingJobType);
if (job == null || job.HasExited)
{
diff --git a/Jellyfin.Api/Controllers/FilterController.cs b/Jellyfin.Api/Controllers/FilterController.cs
index 008bb58d1..9220b988f 100644
--- a/Jellyfin.Api/Controllers/FilterController.cs
+++ b/Jellyfin.Api/Controllers/FilterController.cs
@@ -1,6 +1,7 @@
-using System;
+using System;
using System.Linq;
using Jellyfin.Api.Constants;
+using Jellyfin.Api.ModelBinders;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
@@ -49,37 +50,29 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryFiltersLegacy> GetQueryFiltersLegacy(
[FromQuery] Guid? userId,
- [FromQuery] string? parentId,
- [FromQuery] string? includeItemTypes,
- [FromQuery] string? mediaTypes)
+ [FromQuery] Guid? parentId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes)
{
- var parentItem = string.IsNullOrEmpty(parentId)
- ? null
- : _libraryManager.GetItemById(parentId);
-
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
: null;
- if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+ BaseItem? item = null;
+ if (includeItemTypes.Length != 1
+ || !(string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase)))
{
- parentItem = null;
+ item = _libraryManager.GetParentItem(parentId, user?.Id);
}
- var item = string.IsNullOrEmpty(parentId)
- ? user == null
- ? _libraryManager.RootFolder
- : _libraryManager.GetUserRootFolder()
- : parentItem;
-
var query = new InternalItemsQuery
{
User = user,
- MediaTypes = (mediaTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
- IncludeItemTypes = (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
+ MediaTypes = mediaTypes,
+ IncludeItemTypes = includeItemTypes,
Recursive = true,
EnableTotalRecordCount = false,
DtoOptions = new DtoOptions
@@ -90,7 +83,12 @@ namespace Jellyfin.Api.Controllers
}
};
- var itemList = ((Folder)item!).GetItemList(query);
+ if (item is not Folder folder)
+ {
+ return new QueryFiltersLegacy();
+ }
+
+ var itemList = folder.GetItemList(query);
return new QueryFiltersLegacy
{
Years = itemList.Select(i => i.ProductionYear ?? -1)
@@ -138,8 +136,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryFilters> GetQueryFilters(
[FromQuery] Guid? userId,
- [FromQuery] string? parentId,
- [FromQuery] string? includeItemTypes,
+ [FromQuery] Guid? parentId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isAiring,
[FromQuery] bool? isMovie,
[FromQuery] bool? isSports,
@@ -148,27 +146,28 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? isSeries,
[FromQuery] bool? recursive)
{
- var parentItem = string.IsNullOrEmpty(parentId)
- ? null
- : _libraryManager.GetItemById(parentId);
-
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
: null;
- if (string.Equals(includeItemTypes, nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Playlist), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Trailer), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, "Program", StringComparison.OrdinalIgnoreCase))
+ BaseItem? parentItem = null;
+ if (includeItemTypes.Length == 1
+ && (string.Equals(includeItemTypes[0], nameof(BoxSet), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Playlist), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Trailer), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], "Program", StringComparison.OrdinalIgnoreCase)))
{
parentItem = null;
}
+ else if (parentId.HasValue)
+ {
+ parentItem = _libraryManager.GetItemById(parentId.Value);
+ }
var filters = new QueryFilters();
var genreQuery = new InternalItemsQuery(user)
{
- IncludeItemTypes =
- (includeItemTypes ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries),
+ IncludeItemTypes = includeItemTypes,
DtoOptions = new DtoOptions
{
Fields = Array.Empty<ItemFields>(),
@@ -192,10 +191,11 @@ namespace Jellyfin.Api.Controllers
genreQuery.Parent = parentItem;
}
- if (string.Equals(includeItemTypes, nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, nameof(Audio), StringComparison.OrdinalIgnoreCase))
+ if (includeItemTypes.Length == 1
+ && (string.Equals(includeItemTypes[0], nameof(MusicAlbum), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(MusicVideo), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(MusicArtist), StringComparison.OrdinalIgnoreCase)
+ || string.Equals(includeItemTypes[0], nameof(Audio), StringComparison.OrdinalIgnoreCase)))
{
filters.Genres = _libraryManager.GetMusicGenres(genreQuery).Items.Select(i => new NameGuidPair
{
diff --git a/Jellyfin.Api/Controllers/GenresController.cs b/Jellyfin.Api/Controllers/GenresController.cs
index 9c222135d..b6755ed5e 100644
--- a/Jellyfin.Api/Controllers/GenresController.cs
+++ b/Jellyfin.Api/Controllers/GenresController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
@@ -72,10 +72,10 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isFavorite,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
@@ -109,15 +109,15 @@ namespace Jellyfin.Api.Controllers
EnableTotalRecordCount = enableTotalRecordCount
};
- if (!string.IsNullOrWhiteSpace(parentId))
+ if (parentId.HasValue)
{
if (parentItem is Folder)
{
- query.AncestorIds = new[] { new Guid(parentId) };
+ query.AncestorIds = new[] { parentId.Value };
}
else
{
- query.ItemIds = new[] { new Guid(parentId) };
+ query.ItemIds = new[] { parentId.Value };
}
}
@@ -133,7 +133,7 @@ namespace Jellyfin.Api.Controllers
result = _libraryManager.GetGenres(query);
}
- var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+ var shouldIncludeItemTypes = includeItemTypes.Length != 0;
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs
index 3b75e8d43..f51987732 100644
--- a/Jellyfin.Api/Controllers/HlsSegmentController.cs
+++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.IO;
@@ -112,11 +112,13 @@ namespace Jellyfin.Api.Controllers
/// <param name="segmentId">The segment id.</param>
/// <param name="segmentContainer">The segment container.</param>
/// <response code="200">Hls video segment returned.</response>
+ /// <response code="404">Hls segment not found.</response>
/// <returns>A <see cref="FileStreamResult"/> containing the video segment.</returns>
// Can't require authentication just yet due to seeing some requests come from Chrome without full query string
// [Authenticated]
[HttpGet("Videos/{itemId}/hls/{playlistId}/{segmentId}.{segmentContainer}")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesVideoFile]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")]
public ActionResult GetHlsVideoSegmentLegacy(
@@ -132,13 +134,25 @@ namespace Jellyfin.Api.Controllers
var normalizedPlaylistId = playlistId;
- var playlistPath = _fileSystem.GetFilePaths(transcodeFolderPath)
- .FirstOrDefault(i =>
- string.Equals(Path.GetExtension(i), ".m3u8", StringComparison.OrdinalIgnoreCase)
- && i.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
- ?? throw new ResourceNotFoundException($"Provided path ({transcodeFolderPath}) is not valid.");
+ var filePaths = _fileSystem.GetFilePaths(transcodeFolderPath);
+ // Add . to start of segment container for future use.
+ segmentContainer = segmentContainer.Insert(0, ".");
+ string? playlistPath = null;
+ foreach (var path in filePaths)
+ {
+ var pathExtension = Path.GetExtension(path);
+ if ((string.Equals(pathExtension, segmentContainer, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(pathExtension, ".m3u8", StringComparison.OrdinalIgnoreCase))
+ && path.IndexOf(normalizedPlaylistId, StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ playlistPath = path;
+ break;
+ }
+ }
- return GetFileResult(file, playlistPath);
+ return playlistPath == null
+ ? NotFound("Hls segment not found.")
+ : GetFileResult(file, playlistPath);
}
private ActionResult GetFileResult(string path, string playlistPath)
diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs
index 76e53b9a5..65de81d7a 100644
--- a/Jellyfin.Api/Controllers/ImageController.cs
+++ b/Jellyfin.Api/Controllers/ImageController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
@@ -86,7 +86,6 @@ namespace Jellyfin.Api.Controllers
/// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Users/{userId}/Images/{imageType}")]
- [HttpPost("Users/{userId}/Images/{imageType}/{index?}", Name = "PostUserImage_2")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
@@ -95,7 +94,53 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult> PostUserImage(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
- [FromRoute] int? index = null)
+ [FromQuery] int? index = null)
+ {
+ if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+ {
+ return Forbid("User is not allowed to update the image.");
+ }
+
+ var user = _userManager.GetUserById(userId);
+ await using var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false);
+
+ // Handle image/png; charset=utf-8
+ var mimeType = Request.ContentType.Split(';').FirstOrDefault();
+ var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
+ if (user.ProfileImage != null)
+ {
+ await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+ }
+
+ user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + MimeTypes.ToExtension(mimeType)));
+
+ await _providerManager
+ .SaveImage(memoryStream, mimeType, user.ProfileImage.Path)
+ .ConfigureAwait(false);
+ await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Sets the user image.
+ /// </summary>
+ /// <param name="userId">User Id.</param>
+ /// <param name="imageType">(Unused) Image type.</param>
+ /// <param name="index">(Unused) Image index.</param>
+ /// <response code="204">Image updated.</response>
+ /// <response code="403">User does not have permission to delete the image.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpPost("Users/{userId}/Images/{imageType}/{index}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+ public async Task<ActionResult> PostUserImageByIndex(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute] int index)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
{
@@ -132,8 +177,7 @@ namespace Jellyfin.Api.Controllers
/// <response code="204">Image deleted.</response>
/// <response code="403">User does not have permission to delete the image.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
- [HttpDelete("Users/{userId}/Images/{itemType}")]
- [HttpDelete("Users/{userId}/Images/{itemType}/{index?}", Name = "DeleteUserImage_2")]
+ [HttpDelete("Users/{userId}/Images/{imageType}")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
@@ -142,7 +186,46 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult> DeleteUserImage(
[FromRoute, Required] Guid userId,
[FromRoute, Required] ImageType imageType,
- [FromRoute] int? index = null)
+ [FromQuery] int? index = null)
+ {
+ if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
+ {
+ return Forbid("User is not allowed to delete the image.");
+ }
+
+ var user = _userManager.GetUserById(userId);
+ try
+ {
+ System.IO.File.Delete(user.ProfileImage.Path);
+ }
+ catch (IOException e)
+ {
+ _logger.LogError(e, "Error deleting user profile image:");
+ }
+
+ await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Delete the user's image.
+ /// </summary>
+ /// <param name="userId">User Id.</param>
+ /// <param name="imageType">(Unused) Image type.</param>
+ /// <param name="index">(Unused) Image index.</param>
+ /// <response code="204">Image deleted.</response>
+ /// <response code="403">User does not have permission to delete the image.</response>
+ /// <returns>A <see cref="NoContentResult"/>.</returns>
+ [HttpDelete("Users/{userId}/Images/{imageType}/{index}")]
+ [Authorize(Policy = Policies.DefaultAuthorization)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageType", Justification = "Imported from ServiceStack")]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status403Forbidden)]
+ public async Task<ActionResult> DeleteUserImageByIndex(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute] int index)
{
if (!RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, userId, true))
{
@@ -173,14 +256,13 @@ namespace Jellyfin.Api.Controllers
/// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
[HttpDelete("Items/{itemId}/Images/{imageType}")]
- [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "DeleteItemImage_2")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteItemImage(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -193,24 +275,82 @@ namespace Jellyfin.Api.Controllers
}
/// <summary>
+ /// Delete an item's image.
+ /// </summary>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="imageType">Image type.</param>
+ /// <param name="imageIndex">The image index.</param>
+ /// <response code="204">Image deleted.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+ [HttpDelete("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task<ActionResult> DeleteItemImageByIndex(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute] int imageIndex)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ await item.DeleteImageAsync(imageType, imageIndex).ConfigureAwait(false);
+ return NoContent();
+ }
+
+ /// <summary>
/// Set item image.
/// </summary>
/// <param name="itemId">Item id.</param>
/// <param name="imageType">Image type.</param>
- /// <param name="imageIndex">(Unused) Image index.</param>
/// <response code="204">Image saved.</response>
/// <response code="404">Item not found.</response>
/// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
[HttpPost("Items/{itemId}/Images/{imageType}")]
- [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "SetItemImage_2")]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> SetItemImage(
[FromRoute, Required] Guid itemId,
+ [FromRoute, Required] ImageType imageType)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ // Handle image/png; charset=utf-8
+ var mimeType = Request.ContentType.Split(';').FirstOrDefault();
+ await _providerManager.SaveImage(item, Request.Body, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false);
+ await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
+
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Set item image.
+ /// </summary>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="imageType">Image type.</param>
+ /// <param name="imageIndex">(Unused) Image index.</param>
+ /// <response code="204">Image saved.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>A <see cref="NoContentResult"/> on success, or a <see cref="NotFoundResult"/> if item not found.</returns>
+ [HttpPost("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+ [Authorize(Policy = Policies.RequiresElevation)]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "index", Justification = "Imported from ServiceStack")]
+ public async Task<ActionResult> SetItemImageByIndex(
+ [FromRoute, Required] Guid itemId,
[FromRoute, Required] ImageType imageType,
- [FromRoute] int? imageIndex = null)
+ [FromRoute] int imageIndex)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -350,8 +490,6 @@ namespace Jellyfin.Api.Controllers
/// </returns>
[HttpGet("Items/{itemId}/Images/{imageType}")]
[HttpHead("Items/{itemId}/Images/{imageType}", Name = "HeadItemImage")]
- [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "GetItemImage_2")]
- [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex?}", Name = "HeadItemImage_2")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -372,7 +510,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetItemById(itemId);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ itemId,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Gets the item's image.
+ /// </summary>
+ /// <param name="itemId">Item id.</param>
+ /// <param name="imageType">Image type.</param>
+ /// <param name="imageIndex">Image index.</param>
+ /// <param name="maxWidth">The maximum image width to return.</param>
+ /// <param name="maxHeight">The maximum image height to return.</param>
+ /// <param name="width">The fixed image width to return.</param>
+ /// <param name="height">The fixed image height to return.</param>
+ /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+ /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+ /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+ /// <param name="format">Optional. The <see cref="ImageFormat"/> of the returned image.</param>
+ /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+ /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+ /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+ /// <param name="blur">Optional. Blur image.</param>
+ /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+ /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+ /// <response code="200">Image stream returned.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>
+ /// A <see cref="FileStreamResult"/> containing the file stream on success,
+ /// or a <see cref="NotFoundResult"/> if item not found.
+ /// </returns>
+ [HttpGet("Items/{itemId}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Items/{itemId}/Images/{imageType}/{imageIndex}", Name = "HeadItemImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task<ActionResult> GetItemImageByIndex(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute] int imageIndex,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] string? tag,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetItemById(itemId);
if (item == null)
@@ -508,8 +725,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
- [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadArtistImage")]
+ [HttpGet("Artists/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Artists/{name}/Images/{imageType}/{imageIndex}", Name = "HeadArtistImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -587,8 +804,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
- [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadGenreImage")]
+ [HttpGet("Genres/{name}/Images/{imageType}")]
+ [HttpHead("Genres/{name}/Images/{imageType}", Name = "HeadGenreImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -609,7 +826,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetGenre(name);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ item.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Get genre image by name.
+ /// </summary>
+ /// <param name="name">Genre name.</param>
+ /// <param name="imageType">Image type.</param>
+ /// <param name="imageIndex">Image index.</param>
+ /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+ /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+ /// <param name="maxWidth">The maximum image width to return.</param>
+ /// <param name="maxHeight">The maximum image height to return.</param>
+ /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+ /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+ /// <param name="width">The fixed image width to return.</param>
+ /// <param name="height">The fixed image height to return.</param>
+ /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+ /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+ /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+ /// <param name="blur">Optional. Blur image.</param>
+ /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+ /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+ /// <response code="200">Image stream returned.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>
+ /// A <see cref="FileStreamResult"/> containing the file stream on success,
+ /// or a <see cref="NotFoundResult"/> if item not found.
+ /// </returns>
+ [HttpGet("Genres/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Genres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadGenreImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task<ActionResult> GetGenreImageByIndex(
+ [FromRoute, Required] string name,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetGenre(name);
if (item == null)
@@ -666,8 +962,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
- [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadMusicGenreImage")]
+ [HttpGet("MusicGenres/{name}/Images/{imageType}")]
+ [HttpHead("MusicGenres/{name}/Images/{imageType}", Name = "HeadMusicGenreImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -688,7 +984,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetMusicGenre(name);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ item.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Get music genre image by name.
+ /// </summary>
+ /// <param name="name">Music genre name.</param>
+ /// <param name="imageType">Image type.</param>
+ /// <param name="imageIndex">Image index.</param>
+ /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+ /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+ /// <param name="maxWidth">The maximum image width to return.</param>
+ /// <param name="maxHeight">The maximum image height to return.</param>
+ /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+ /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+ /// <param name="width">The fixed image width to return.</param>
+ /// <param name="height">The fixed image height to return.</param>
+ /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+ /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+ /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+ /// <param name="blur">Optional. Blur image.</param>
+ /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+ /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+ /// <response code="200">Image stream returned.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>
+ /// A <see cref="FileStreamResult"/> containing the file stream on success,
+ /// or a <see cref="NotFoundResult"/> if item not found.
+ /// </returns>
+ [HttpGet("MusicGenres/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("MusicGenres/{name}/Images/{imageType}/{imageIndex}", Name = "HeadMusicGenreImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task<ActionResult> GetMusicGenreImageByIndex(
+ [FromRoute, Required] string name,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetMusicGenre(name);
if (item == null)
@@ -745,8 +1120,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
- [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadPersonImage")]
+ [HttpGet("Persons/{name}/Images/{imageType}")]
+ [HttpHead("Persons/{name}/Images/{imageType}", Name = "HeadPersonImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -767,7 +1142,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetPerson(name);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ item.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Get person image by name.
+ /// </summary>
+ /// <param name="name">Person name.</param>
+ /// <param name="imageType">Image type.</param>
+ /// <param name="imageIndex">Image index.</param>
+ /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+ /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+ /// <param name="maxWidth">The maximum image width to return.</param>
+ /// <param name="maxHeight">The maximum image height to return.</param>
+ /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+ /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+ /// <param name="width">The fixed image width to return.</param>
+ /// <param name="height">The fixed image height to return.</param>
+ /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+ /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+ /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+ /// <param name="blur">Optional. Blur image.</param>
+ /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+ /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+ /// <response code="200">Image stream returned.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>
+ /// A <see cref="FileStreamResult"/> containing the file stream on success,
+ /// or a <see cref="NotFoundResult"/> if item not found.
+ /// </returns>
+ [HttpGet("Persons/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Persons/{name}/Images/{imageType}/{imageIndex}", Name = "HeadPersonImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task<ActionResult> GetPersonImageByIndex(
+ [FromRoute, Required] string name,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetPerson(name);
if (item == null)
@@ -824,16 +1278,16 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
- [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex?}", Name = "HeadStudioImage")]
+ [HttpGet("Studios/{name}/Images/{imageType}")]
+ [HttpHead("Studios/{name}/Images/{imageType}", Name = "HeadStudioImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
public async Task<ActionResult> GetStudioImage(
[FromRoute, Required] string name,
[FromRoute, Required] ImageType imageType,
- [FromRoute, Required] string tag,
- [FromRoute, Required] ImageFormat format,
+ [FromQuery] string? tag,
+ [FromQuery] ImageFormat? format,
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] double? percentPlayed,
@@ -846,7 +1300,86 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
+ {
+ var item = _libraryManager.GetStudio(name);
+ if (item == null)
+ {
+ return NotFound();
+ }
+
+ return await GetImageInternal(
+ item.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ item,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase))
+ .ConfigureAwait(false);
+ }
+
+ /// <summary>
+ /// Get studio image by name.
+ /// </summary>
+ /// <param name="name">Studio name.</param>
+ /// <param name="imageType">Image type.</param>
+ /// <param name="imageIndex">Image index.</param>
+ /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+ /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+ /// <param name="maxWidth">The maximum image width to return.</param>
+ /// <param name="maxHeight">The maximum image height to return.</param>
+ /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+ /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+ /// <param name="width">The fixed image width to return.</param>
+ /// <param name="height">The fixed image height to return.</param>
+ /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+ /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+ /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+ /// <param name="blur">Optional. Blur image.</param>
+ /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+ /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+ /// <response code="200">Image stream returned.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>
+ /// A <see cref="FileStreamResult"/> containing the file stream on success,
+ /// or a <see cref="NotFoundResult"/> if item not found.
+ /// </returns>
+ [HttpGet("Studios/{name}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Studios/{name}/Images/{imageType}/{imageIndex}", Name = "HeadStudioImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task<ActionResult> GetStudioImageByIndex(
+ [FromRoute, Required] string name,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string? tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
{
var item = _libraryManager.GetStudio(name);
if (item == null)
@@ -903,8 +1436,8 @@ namespace Jellyfin.Api.Controllers
/// A <see cref="FileStreamResult"/> containing the file stream on success,
/// or a <see cref="NotFoundResult"/> if item not found.
/// </returns>
- [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex?}")]
- [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex?}", Name = "HeadUserImage")]
+ [HttpGet("Users/{userId}/Images/{imageType}")]
+ [HttpHead("Users/{userId}/Images/{imageType}", Name = "HeadUserImage")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesImageFile]
@@ -925,7 +1458,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? blur,
[FromQuery] string? backgroundColor,
[FromQuery] string? foregroundLayer,
- [FromRoute] int? imageIndex = null)
+ [FromQuery] int? imageIndex)
{
var user = _userManager.GetUserById(userId);
if (user == null)
@@ -974,6 +1507,103 @@ namespace Jellyfin.Api.Controllers
.ConfigureAwait(false);
}
+ /// <summary>
+ /// Get user profile image.
+ /// </summary>
+ /// <param name="userId">User id.</param>
+ /// <param name="imageType">Image type.</param>
+ /// <param name="imageIndex">Image index.</param>
+ /// <param name="tag">Optional. Supply the cache tag from the item object to receive strong caching headers.</param>
+ /// <param name="format">Determines the output format of the image - original,gif,jpg,png.</param>
+ /// <param name="maxWidth">The maximum image width to return.</param>
+ /// <param name="maxHeight">The maximum image height to return.</param>
+ /// <param name="percentPlayed">Optional. Percent to render for the percent played overlay.</param>
+ /// <param name="unplayedCount">Optional. Unplayed count overlay to render.</param>
+ /// <param name="width">The fixed image width to return.</param>
+ /// <param name="height">The fixed image height to return.</param>
+ /// <param name="quality">Optional. Quality setting, from 0-100. Defaults to 90 and should suffice in most cases.</param>
+ /// <param name="cropWhitespace">Optional. Specify if whitespace should be cropped out of the image. True/False. If unspecified, whitespace will be cropped from logos and clear art.</param>
+ /// <param name="addPlayedIndicator">Optional. Add a played indicator.</param>
+ /// <param name="blur">Optional. Blur image.</param>
+ /// <param name="backgroundColor">Optional. Apply a background color for transparent images.</param>
+ /// <param name="foregroundLayer">Optional. Apply a foreground layer on top of the image.</param>
+ /// <response code="200">Image stream returned.</response>
+ /// <response code="404">Item not found.</response>
+ /// <returns>
+ /// A <see cref="FileStreamResult"/> containing the file stream on success,
+ /// or a <see cref="NotFoundResult"/> if item not found.
+ /// </returns>
+ [HttpGet("Users/{userId}/Images/{imageType}/{imageIndex}")]
+ [HttpHead("Users/{userId}/Images/{imageType}/{imageIndex}", Name = "HeadUserImageByIndex")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesImageFile]
+ public async Task<ActionResult> GetUserImageByIndex(
+ [FromRoute, Required] Guid userId,
+ [FromRoute, Required] ImageType imageType,
+ [FromRoute, Required] int imageIndex,
+ [FromQuery] string? tag,
+ [FromQuery] ImageFormat? format,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] double? percentPlayed,
+ [FromQuery] int? unplayedCount,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? quality,
+ [FromQuery] bool? cropWhitespace,
+ [FromQuery] bool? addPlayedIndicator,
+ [FromQuery] int? blur,
+ [FromQuery] string? backgroundColor,
+ [FromQuery] string? foregroundLayer)
+ {
+ var user = _userManager.GetUserById(userId);
+ if (user?.ProfileImage == null)
+ {
+ return NotFound();
+ }
+
+ var info = new ItemImageInfo
+ {
+ Path = user.ProfileImage.Path,
+ Type = ImageType.Profile,
+ DateModified = user.ProfileImage.LastModified
+ };
+
+ if (width.HasValue)
+ {
+ info.Width = width.Value;
+ }
+
+ if (height.HasValue)
+ {
+ info.Height = height.Value;
+ }
+
+ return await GetImageInternal(
+ user.Id,
+ imageType,
+ imageIndex,
+ tag,
+ format,
+ maxWidth,
+ maxHeight,
+ percentPlayed,
+ unplayedCount,
+ width,
+ height,
+ quality,
+ cropWhitespace,
+ addPlayedIndicator,
+ blur,
+ backgroundColor,
+ foregroundLayer,
+ null,
+ Request.Method.Equals(HttpMethods.Head, StringComparison.OrdinalIgnoreCase),
+ info)
+ .ConfigureAwait(false);
+ }
+
private static async Task<MemoryStream> GetMemoryStream(Stream inputStream)
{
using var reader = new StreamReader(inputStream);
diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs
index d17a26db4..244625752 100644
--- a/Jellyfin.Api/Controllers/InstantMixController.cs
+++ b/Jellyfin.Api/Controllers/InstantMixController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@@ -206,7 +206,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("Artists/InstantMix")]
+ [HttpGet("Artists/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromArtists(
[FromRoute, Required] Guid id,
@@ -242,7 +242,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="enableImageTypes">Optional. The image types to include in the output.</param>
/// <response code="200">Instant playlist returned.</response>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
- [HttpGet("MusicGenres/InstantMix")]
+ [HttpGet("MusicGenres/{id}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenres(
[FromRoute, Required] Guid id,
diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs
index a7c1a6388..6c38f77ce 100644
--- a/Jellyfin.Api/Controllers/ItemLookupController.cs
+++ b/Jellyfin.Api/Controllers/ItemLookupController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 0a6ed31ae..9e1a39853 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index d8d371ebc..7e9035f80 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
@@ -60,7 +60,6 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Gets items based on a query.
/// </summary>
- /// <param name="uId">The user id supplied in the /Users/{uid}/Items.</param>
/// <param name="userId">The user id supplied as query parameter.</param>
/// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
/// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
@@ -73,8 +72,8 @@ namespace Jellyfin.Api.Controllers
/// <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>
/// <param name="is4K">Optional filter by items that are 4K or not.</param>
- /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
- /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
+ /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
/// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
/// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
@@ -87,42 +86,42 @@ namespace Jellyfin.Api.Controllers
/// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
/// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
/// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
- /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
/// <param name="searchTerm">Optional. Filter based on a search term.</param>
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
- /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
- /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
- /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
+ /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
- /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</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="isPlayed">Optional filter by items that are played, or not.</param>
- /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
- /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
- /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
- /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
+ /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+ /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+ /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+ /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
/// <param name="enableUserData">Optional, include user data.</param>
/// <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>
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
- /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
- /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
- /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
+ /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+ /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
+ /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
/// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
/// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
/// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
- /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
- /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
+ /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
+ /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
/// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
- /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
+ /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
/// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
/// <param name="isLocked">Optional filter by items that are locked.</param>
/// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
@@ -133,20 +132,18 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
/// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
/// <param name="is3D">Optional filter by items that are 3D, or not.</param>
- /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
+ /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
- /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
- /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
+ /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+ /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
[HttpGet("Items")]
- [HttpGet("Users/{uId}/Items", Name = "GetItems_2")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetItems(
- [FromRoute] Guid? uId,
[FromQuery] Guid? userId,
[FromQuery] string? maxOfficialRating,
[FromQuery] bool? hasThemeSong,
@@ -159,7 +156,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd,
[FromQuery] bool? is4K,
- [FromQuery] string? locationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired,
@@ -173,42 +170,42 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasImdbId,
[FromQuery] bool? hasTmdbId,
[FromQuery] bool? hasTvdbId,
- [FromQuery] string? excludeItemIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] bool? recursive,
[FromQuery] string? searchTerm,
[FromQuery] string? sortOrder,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
- [FromQuery] string? mediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
[FromQuery] string? sortBy,
[FromQuery] bool? isPlayed,
- [FromQuery] string? genres,
- [FromQuery] string? officialRatings,
- [FromQuery] string? tags,
- [FromQuery] string? years,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person,
- [FromQuery] string? personIds,
- [FromQuery] string? personTypes,
- [FromQuery] string? studios,
- [FromQuery] string? artists,
- [FromQuery] string? excludeArtistIds,
- [FromQuery] string? artistIds,
- [FromQuery] string? albumArtistIds,
- [FromQuery] string? contributingArtistIds,
- [FromQuery] string? albums,
- [FromQuery] string? albumIds,
- [FromQuery] string? ids,
- [FromQuery] string? videoTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
[FromQuery] string? minOfficialRating,
[FromQuery] bool? isLocked,
[FromQuery] bool? isPlaceHolder,
@@ -219,18 +216,15 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] bool? is3D,
- [FromQuery] string? seriesStatus,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
[FromQuery] string? nameLessThan,
- [FromQuery] string? studioIds,
- [FromQuery] string? genreIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true)
{
- // use user id route parameter over query parameter
- userId = uId ?? userId;
-
var user = userId.HasValue && !userId.Equals(Guid.Empty)
? _userManager.GetUserById(userId.Value)
: null;
@@ -238,20 +232,15 @@ namespace Jellyfin.Api.Controllers
.AddClientFields(Request)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
- if (string.Equals(includeItemTypes, "Playlist", StringComparison.OrdinalIgnoreCase)
- || string.Equals(includeItemTypes, "BoxSet", StringComparison.OrdinalIgnoreCase))
+ if (includeItemTypes.Length == 1
+ && (includeItemTypes[0].Equals("Playlist", StringComparison.OrdinalIgnoreCase)
+ || includeItemTypes[0].Equals("BoxSet", StringComparison.OrdinalIgnoreCase)))
{
parentId = null;
}
- BaseItem? item = null;
+ var item = _libraryManager.GetParentItem(parentId, userId);
QueryResult<BaseItem> result;
- if (!string.IsNullOrEmpty(parentId))
- {
- item = _libraryManager.GetItemById(parentId);
- }
-
- item ??= _libraryManager.GetUserRootFolder();
if (!(item is Folder folder))
{
@@ -262,7 +251,7 @@ namespace Jellyfin.Api.Controllers
&& string.Equals(hasCollectionType.CollectionType, CollectionType.Playlists, StringComparison.OrdinalIgnoreCase))
{
recursive = true;
- includeItemTypes = "Playlist";
+ includeItemTypes = new[] { "Playlist" };
}
bool isInEnabledFolder = user!.GetPreference(PreferenceKind.EnabledFolders).Any(i => new Guid(i) == item.Id)
@@ -291,14 +280,14 @@ namespace Jellyfin.Api.Controllers
return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
}
- if ((recursive.HasValue && recursive.Value) || !string.IsNullOrEmpty(ids) || !(item is UserRootFolder))
+ if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || !(item is UserRootFolder))
{
var query = new InternalItemsQuery(user!)
{
IsPlayed = isPlayed,
- MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
- ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+ MediaTypes = mediaTypes,
+ IncludeItemTypes = includeItemTypes,
+ ExcludeItemTypes = excludeItemTypes,
Recursive = recursive ?? false,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
IsFavorite = isFavorite,
@@ -330,28 +319,28 @@ namespace Jellyfin.Api.Controllers
HasTrailer = hasTrailer,
IsHD = isHd,
Is4K = is4K,
- Tags = RequestHelpers.Split(tags, '|', true),
- OfficialRatings = RequestHelpers.Split(officialRatings, '|', true),
- Genres = RequestHelpers.Split(genres, '|', true),
- ArtistIds = RequestHelpers.GetGuids(artistIds),
- AlbumArtistIds = RequestHelpers.GetGuids(albumArtistIds),
- ContributingArtistIds = RequestHelpers.GetGuids(contributingArtistIds),
- GenreIds = RequestHelpers.GetGuids(genreIds),
- StudioIds = RequestHelpers.GetGuids(studioIds),
+ Tags = tags,
+ OfficialRatings = officialRatings,
+ Genres = genres,
+ ArtistIds = artistIds,
+ AlbumArtistIds = albumArtistIds,
+ ContributingArtistIds = contributingArtistIds,
+ GenreIds = genreIds,
+ StudioIds = studioIds,
Person = person,
- PersonIds = RequestHelpers.GetGuids(personIds),
- PersonTypes = RequestHelpers.Split(personTypes, ',', true),
- Years = RequestHelpers.Split(years, ',', true).Select(int.Parse).ToArray(),
+ PersonIds = personIds,
+ PersonTypes = personTypes,
+ Years = years,
ImageTypes = imageTypes,
- VideoTypes = RequestHelpers.Split(videoTypes, ',', true).Select(v => Enum.Parse<VideoType>(v, true)).ToArray(),
+ VideoTypes = videoTypes,
AdjacentTo = adjacentTo,
- ItemIds = RequestHelpers.GetGuids(ids),
+ ItemIds = ids,
MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating,
- ParentId = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId),
+ ParentId = parentId ?? Guid.Empty,
ParentIndexNumber = parentIndexNumber,
EnableTotalRecordCount = enableTotalRecordCount,
- ExcludeItemIds = RequestHelpers.GetGuids(excludeItemIds),
+ ExcludeItemIds = excludeItemIds,
DtoOptions = dtoOptions,
SearchTerm = searchTerm,
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
@@ -360,7 +349,7 @@ namespace Jellyfin.Api.Controllers
MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
};
- if (!string.IsNullOrWhiteSpace(ids) || !string.IsNullOrWhiteSpace(searchTerm))
+ if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
{
query.CollapseBoxSetItems = false;
}
@@ -400,9 +389,9 @@ namespace Jellyfin.Api.Controllers
}
// Filter by Series Status
- if (!string.IsNullOrEmpty(seriesStatus))
+ if (seriesStatus.Length != 0)
{
- query.SeriesStatuses = seriesStatus.Split(',').Select(d => (SeriesStatus)Enum.Parse(typeof(SeriesStatus), d, true)).ToArray();
+ query.SeriesStatuses = seriesStatus;
}
// ExcludeLocationTypes
@@ -411,13 +400,9 @@ namespace Jellyfin.Api.Controllers
query.IsVirtualItem = false;
}
- if (!string.IsNullOrEmpty(locationTypes))
+ if (locationTypes.Length > 0 && locationTypes.Length < 4)
{
- var requestedLocationTypes = locationTypes.Split(',');
- if (requestedLocationTypes.Length > 0 && requestedLocationTypes.Length < 4)
- {
- query.IsVirtualItem = requestedLocationTypes.Contains(LocationType.Virtual.ToString());
- }
+ query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
}
// Min official rating
@@ -433,9 +418,9 @@ namespace Jellyfin.Api.Controllers
}
// Artists
- if (!string.IsNullOrEmpty(artists))
+ if (artists.Length != 0)
{
- query.ArtistIds = artists.Split('|').Select(i =>
+ query.ArtistIds = artists.Select(i =>
{
try
{
@@ -449,29 +434,29 @@ namespace Jellyfin.Api.Controllers
}
// ExcludeArtistIds
- if (!string.IsNullOrWhiteSpace(excludeArtistIds))
+ if (excludeArtistIds.Length != 0)
{
- query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+ query.ExcludeArtistIds = excludeArtistIds;
}
- if (!string.IsNullOrWhiteSpace(albumIds))
+ if (albumIds.Length != 0)
{
- query.AlbumIds = RequestHelpers.GetGuids(albumIds);
+ query.AlbumIds = albumIds;
}
// Albums
- if (!string.IsNullOrEmpty(albums))
+ if (albums.Length != 0)
{
- query.AlbumIds = albums.Split('|').SelectMany(i =>
+ query.AlbumIds = albums.SelectMany(i =>
{
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { nameof(MusicAlbum) }, Name = i, Limit = 1 });
}).ToArray();
}
// Studios
- if (!string.IsNullOrEmpty(studios))
+ if (studios.Length != 0)
{
- query.StudioIds = studios.Split('|').Select(i =>
+ query.StudioIds = studios.Select(i =>
{
try
{
@@ -508,18 +493,269 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Gets items based on a query.
/// </summary>
+ /// <param name="userId">The user id supplied as query parameter.</param>
+ /// <param name="maxOfficialRating">Optional filter by maximum official rating (PG, PG-13, TV-MA, etc).</param>
+ /// <param name="hasThemeSong">Optional filter by items with theme songs.</param>
+ /// <param name="hasThemeVideo">Optional filter by items with theme videos.</param>
+ /// <param name="hasSubtitles">Optional filter by items with subtitles.</param>
+ /// <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="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>
+ /// <param name="is4K">Optional filter by items that are 4K or not.</param>
+ /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
+ /// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
+ /// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
+ /// <param name="minCriticRating">Optional filter by minimum critic rating.</param>
+ /// <param name="minPremiereDate">Optional. The minimum premiere date. Format = ISO.</param>
+ /// <param name="minDateLastSaved">Optional. The minimum last saved date. Format = ISO.</param>
+ /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param>
+ /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param>
+ /// <param name="hasOverview">Optional filter by items that have an overview or not.</param>
+ /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
+ /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
+ /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
+ /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
+ /// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
+ /// <param name="limit">Optional. The maximum number of records to return.</param>
+ /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
+ /// <param name="searchTerm">Optional. Filter based on a search term.</param>
+ /// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
+ /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
+ /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+ /// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
+ /// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
+ /// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
+ /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</param>
+ /// <param name="isPlayed">Optional filter by items that are played, or not.</param>
+ /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
+ /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
+ /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
+ /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
+ /// <param name="enableUserData">Optional, include user data.</param>
+ /// <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>
+ /// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
+ /// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
+ /// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
+ /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
+ /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
+ /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
+ /// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
+ /// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
+ /// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
+ /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
+ /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
+ /// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
+ /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
+ /// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
+ /// <param name="isLocked">Optional filter by items that are locked.</param>
+ /// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
+ /// <param name="hasOfficialRating">Optional filter by items that have official ratings.</param>
+ /// <param name="collapseBoxSetItems">Whether or not to hide items behind their boxsets.</param>
+ /// <param name="minWidth">Optional. Filter by the minimum width of the item.</param>
+ /// <param name="minHeight">Optional. Filter by the minimum height of the item.</param>
+ /// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
+ /// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
+ /// <param name="is3D">Optional filter by items that are 3D, or not.</param>
+ /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
+ /// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
+ /// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
+ /// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
+ /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
+ /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
+ /// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
+ /// <param name="enableImages">Optional, include image information in output.</param>
+ /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the items.</returns>
+ [HttpGet("Users/{userId}/Items")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult<QueryResult<BaseItemDto>> GetItemsByUserId(
+ [FromRoute] Guid userId,
+ [FromQuery] string? maxOfficialRating,
+ [FromQuery] bool? hasThemeSong,
+ [FromQuery] bool? hasThemeVideo,
+ [FromQuery] bool? hasSubtitles,
+ [FromQuery] bool? hasSpecialFeature,
+ [FromQuery] bool? hasTrailer,
+ [FromQuery] string? adjacentTo,
+ [FromQuery] int? parentIndexNumber,
+ [FromQuery] bool? hasParentalRating,
+ [FromQuery] bool? isHd,
+ [FromQuery] bool? is4K,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
+ [FromQuery] bool? isMissing,
+ [FromQuery] bool? isUnaired,
+ [FromQuery] double? minCommunityRating,
+ [FromQuery] double? minCriticRating,
+ [FromQuery] DateTime? minPremiereDate,
+ [FromQuery] DateTime? minDateLastSaved,
+ [FromQuery] DateTime? minDateLastSavedForUser,
+ [FromQuery] DateTime? maxPremiereDate,
+ [FromQuery] bool? hasOverview,
+ [FromQuery] bool? hasImdbId,
+ [FromQuery] bool? hasTmdbId,
+ [FromQuery] bool? hasTvdbId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
+ [FromQuery] int? startIndex,
+ [FromQuery] int? limit,
+ [FromQuery] bool? recursive,
+ [FromQuery] string? searchTerm,
+ [FromQuery] string? sortOrder,
+ [FromQuery] Guid? parentId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
+ [FromQuery] bool? isFavorite,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
+ [FromQuery] string? sortBy,
+ [FromQuery] bool? isPlayed,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
+ [FromQuery] bool? enableUserData,
+ [FromQuery] int? imageTypeLimit,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
+ [FromQuery] string? person,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] studios,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] artists,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] albums,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
+ [FromQuery] string? minOfficialRating,
+ [FromQuery] bool? isLocked,
+ [FromQuery] bool? isPlaceHolder,
+ [FromQuery] bool? hasOfficialRating,
+ [FromQuery] bool? collapseBoxSetItems,
+ [FromQuery] int? minWidth,
+ [FromQuery] int? minHeight,
+ [FromQuery] int? maxWidth,
+ [FromQuery] int? maxHeight,
+ [FromQuery] bool? is3D,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
+ [FromQuery] string? nameStartsWithOrGreater,
+ [FromQuery] string? nameStartsWith,
+ [FromQuery] string? nameLessThan,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
+ [FromQuery] bool enableTotalRecordCount = true,
+ [FromQuery] bool? enableImages = true)
+ {
+ return GetItems(
+ userId,
+ maxOfficialRating,
+ hasThemeSong,
+ hasThemeVideo,
+ hasSubtitles,
+ hasSpecialFeature,
+ hasTrailer,
+ adjacentTo,
+ parentIndexNumber,
+ hasParentalRating,
+ isHd,
+ is4K,
+ locationTypes,
+ excludeLocationTypes,
+ isMissing,
+ isUnaired,
+ minCommunityRating,
+ minCriticRating,
+ minPremiereDate,
+ minDateLastSaved,
+ minDateLastSavedForUser,
+ maxPremiereDate,
+ hasOverview,
+ hasImdbId,
+ hasTmdbId,
+ hasTvdbId,
+ excludeItemIds,
+ startIndex,
+ limit,
+ recursive,
+ searchTerm,
+ sortOrder,
+ parentId,
+ fields,
+ excludeItemTypes,
+ includeItemTypes,
+ filters,
+ isFavorite,
+ mediaTypes,
+ imageTypes,
+ sortBy,
+ isPlayed,
+ genres,
+ officialRatings,
+ tags,
+ years,
+ enableUserData,
+ imageTypeLimit,
+ enableImageTypes,
+ person,
+ personIds,
+ personTypes,
+ studios,
+ artists,
+ excludeArtistIds,
+ artistIds,
+ albumArtistIds,
+ contributingArtistIds,
+ albums,
+ albumIds,
+ ids,
+ videoTypes,
+ minOfficialRating,
+ isLocked,
+ isPlaceHolder,
+ hasOfficialRating,
+ collapseBoxSetItems,
+ minWidth,
+ minHeight,
+ maxWidth,
+ maxHeight,
+ is3D,
+ seriesStatus,
+ nameStartsWithOrGreater,
+ nameStartsWith,
+ nameLessThan,
+ studioIds,
+ genreIds,
+ enableTotalRecordCount,
+ enableImages);
+ }
+
+ /// <summary>
+ /// Gets items based on a query.
+ /// </summary>
/// <param name="userId">The user id.</param>
/// <param name="startIndex">The start index.</param>
/// <param name="limit">The item limit.</param>
/// <param name="searchTerm">The search term.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
/// <param name="mediaTypes">Optional. Filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="enableUserData">Optional. Include user data.</param>
/// <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>
- /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
- /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on the item type. This allows multiple, comma delimited.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional. Include image information in output.</param>
/// <response code="200">Items returned.</response>
@@ -531,19 +767,19 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? mediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true)
{
var user = _userManager.GetUserById(userId);
- var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+ var parentIdGuid = parentId ?? Guid.Empty;
var dtoOptions = new DtoOptions { Fields = fields }
.AddClientFields(Request)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
@@ -569,13 +805,13 @@ namespace Jellyfin.Api.Controllers
ParentId = parentIdGuid,
Recursive = true,
DtoOptions = dtoOptions,
- MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+ MediaTypes = mediaTypes,
IsVirtualItem = false,
CollapseBoxSetItems = false,
EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds,
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
- ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
+ IncludeItemTypes = includeItemTypes,
+ ExcludeItemTypes = excludeItemTypes,
SearchTerm = searchTerm
});
diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs
index 1b115d800..184843b39 100644
--- a/Jellyfin.Api/Controllers/LibraryController.cs
+++ b/Jellyfin.Api/Controllers/LibraryController.cs
@@ -362,15 +362,14 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
- public ActionResult DeleteItems([FromQuery] string? ids)
+ public ActionResult DeleteItems([FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{
- if (string.IsNullOrEmpty(ids))
+ if (ids.Length == 0)
{
return NoContent();
}
- var itemIds = RequestHelpers.Split(ids, ',', true);
- foreach (var i in itemIds)
+ foreach (var i in ids)
{
var item = _libraryManager.GetItemById(i);
var auth = _authContext.GetAuthorizationInfo(Request);
@@ -691,7 +690,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSimilarItems(
[FromRoute, Required] Guid itemId,
- [FromQuery] string? excludeArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
[FromQuery] Guid? userId,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields)
@@ -753,9 +752,9 @@ namespace Jellyfin.Api.Controllers
};
// ExcludeArtistIds
- if (!string.IsNullOrEmpty(excludeArtistIds))
+ if (excludeArtistIds.Length != 0)
{
- query.ExcludeArtistIds = RequestHelpers.GetGuids(excludeArtistIds);
+ query.ExcludeArtistIds = excludeArtistIds;
}
List<BaseItem> itemsResult = _libraryManager.GetItemList(query);
diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs
index 29c0f1df4..56d4b3933 100644
--- a/Jellyfin.Api/Controllers/LiveTvController.cs
+++ b/Jellyfin.Api/Controllers/LiveTvController.cs
@@ -17,7 +17,6 @@ using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.LiveTvDtos;
using Jellyfin.Data.Enums;
-using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Dto;
@@ -150,7 +149,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] bool? enableUserData,
- [FromQuery] string? sortBy,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] sortBy,
[FromQuery] SortOrder? sortOrder,
[FromQuery] bool enableFavoriteSorting = false,
[FromQuery] bool addCurrentProgram = true)
@@ -175,7 +174,7 @@ namespace Jellyfin.Api.Controllers
IsNews = isNews,
IsKids = isKids,
IsSports = isSports,
- SortBy = RequestHelpers.Split(sortBy, ',', true),
+ SortBy = sortBy,
SortOrder = sortOrder ?? SortOrder.Ascending,
AddCurrentProgram = addCurrentProgram
},
@@ -539,7 +538,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = Policies.DefaultAuthorization)]
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetLiveTvPrograms(
- [FromQuery] string? channelIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] channelIds,
[FromQuery] Guid? userId,
[FromQuery] DateTime? minStartDate,
[FromQuery] bool? hasAired,
@@ -556,8 +555,8 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit,
[FromQuery] string? sortBy,
[FromQuery] string? sortOrder,
- [FromQuery] string? genres,
- [FromQuery] string? genreIds,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -573,8 +572,7 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user)
{
- ChannelIds = RequestHelpers.Split(channelIds, ',', true)
- .Select(i => new Guid(i)).ToArray(),
+ ChannelIds = channelIds,
HasAired = hasAired,
IsAiring = isAiring,
EnableTotalRecordCount = enableTotalRecordCount,
@@ -591,8 +589,8 @@ namespace Jellyfin.Api.Controllers
IsKids = isKids,
IsSports = isSports,
SeriesTimerId = seriesTimerId,
- Genres = RequestHelpers.Split(genres, '|', true),
- GenreIds = RequestHelpers.GetGuids(genreIds)
+ Genres = genres,
+ GenreIds = genreIds
};
if (librarySeriesId != null && !librarySeriesId.Equals(Guid.Empty))
@@ -628,8 +626,7 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user)
{
- ChannelIds = RequestHelpers.Split(body.ChannelIds, ',', true)
- .Select(i => new Guid(i)).ToArray(),
+ ChannelIds = body.ChannelIds,
HasAired = body.HasAired,
IsAiring = body.IsAiring,
EnableTotalRecordCount = body.EnableTotalRecordCount,
@@ -646,8 +643,8 @@ namespace Jellyfin.Api.Controllers
IsKids = body.IsKids,
IsSports = body.IsSports,
SeriesTimerId = body.SeriesTimerId,
- Genres = RequestHelpers.Split(body.Genres, '|', true),
- GenreIds = RequestHelpers.GetGuids(body.GenreIds)
+ Genres = body.Genres,
+ GenreIds = body.GenreIds
};
if (!body.LibrarySeriesId.Equals(Guid.Empty))
@@ -703,7 +700,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
- [FromQuery] string? genreIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] bool? enableUserData,
[FromQuery] bool enableTotalRecordCount = true)
@@ -723,7 +720,7 @@ namespace Jellyfin.Api.Controllers
IsNews = isNews,
IsSports = isSports,
EnableTotalRecordCount = enableTotalRecordCount,
- GenreIds = RequestHelpers.GetGuids(genreIds)
+ GenreIds = genreIds
};
var dtoOptions = new DtoOptions { Fields = fields }
@@ -1017,7 +1014,9 @@ namespace Jellyfin.Api.Controllers
if (!string.IsNullOrEmpty(pw))
{
using var sha = SHA1.Create();
- listingsProviderInfo.Password = Hex.Encode(sha.ComputeHash(Encoding.UTF8.GetBytes(pw)));
+ // TODO: remove ToLower when Convert.ToHexString supports lowercase
+ // Schedules Direct requires the hex to be lowercase
+ listingsProviderInfo.Password = Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
}
return await _liveTvManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
@@ -1155,7 +1154,8 @@ namespace Jellyfin.Api.Controllers
/// <param name="newDevicesOnly">Only discover new tuners.</param>
/// <response code="200">Tuners returned.</response>
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
- [HttpGet("Tuners/Discvover")]
+ [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
+ [HttpGet("Tuners/Discover")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<TunerHostInfo>>> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
diff --git a/Jellyfin.Api/Controllers/LocalizationController.cs b/Jellyfin.Api/Controllers/LocalizationController.cs
index ef2e7e8b1..3d8b9e0ca 100644
--- a/Jellyfin.Api/Controllers/LocalizationController.cs
+++ b/Jellyfin.Api/Controllers/LocalizationController.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using Jellyfin.Api.Constants;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs
index 186024585..a76dc057a 100644
--- a/Jellyfin.Api/Controllers/MediaInfoController.cs
+++ b/Jellyfin.Api/Controllers/MediaInfoController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Buffers;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@@ -8,7 +8,6 @@ using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.Models.MediaInfoDtos;
-using Jellyfin.Api.Models.VideoDtos;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
@@ -81,6 +80,9 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Gets live playback media info for an item.
/// </summary>
+ /// <remarks>
+ /// For backwards compatibility parameters can be sent via Query or Body, with Query having higher precedence.
+ /// </remarks>
/// <param name="itemId">The item id.</param>
/// <param name="userId">The user id.</param>
/// <param name="maxStreamingBitrate">The maximum streaming bitrate.</param>
@@ -90,13 +92,13 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxAudioChannels">The maximum number of audio channels.</param>
/// <param name="mediaSourceId">The media source id.</param>
/// <param name="liveStreamId">The livestream id.</param>
- /// <param name="deviceProfile">The device profile.</param>
/// <param name="autoOpenLiveStream">Whether to auto open the livestream.</param>
/// <param name="enableDirectPlay">Whether to enable direct play. Default: true.</param>
/// <param name="enableDirectStream">Whether to enable direct stream. Default: true.</param>
/// <param name="enableTranscoding">Whether to enable transcoding. Default: true.</param>
/// <param name="allowVideoStreamCopy">Whether to allow to copy the video stream. Default: true.</param>
/// <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>
/// <returns>A <see cref="Task"/> containing a <see cref="PlaybackInfoResponse"/> with the playback info.</returns>
[HttpPost("Items/{itemId}/PlaybackInfo")]
@@ -111,18 +113,17 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? maxAudioChannels,
[FromQuery] string? mediaSourceId,
[FromQuery] string? liveStreamId,
- [FromBody] DeviceProfileDto? deviceProfile,
- [FromQuery] bool autoOpenLiveStream = false,
- [FromQuery] bool enableDirectPlay = true,
- [FromQuery] bool enableDirectStream = true,
- [FromQuery] bool enableTranscoding = true,
- [FromQuery] bool allowVideoStreamCopy = true,
- [FromQuery] bool allowAudioStreamCopy = true)
+ [FromQuery] bool? autoOpenLiveStream,
+ [FromQuery] bool? enableDirectPlay,
+ [FromQuery] bool? enableDirectStream,
+ [FromQuery] bool? enableTranscoding,
+ [FromQuery] bool? allowVideoStreamCopy,
+ [FromQuery] bool? allowAudioStreamCopy,
+ [FromBody] PlaybackInfoDto? playbackInfoDto)
{
var authInfo = _authContext.GetAuthorizationInfo(Request);
- var profile = deviceProfile?.DeviceProfile;
-
+ var profile = playbackInfoDto?.DeviceProfile;
_logger.LogInformation("GetPostedPlaybackInfo profile: {@Profile}", profile);
if (profile == null)
@@ -134,6 +135,23 @@ namespace Jellyfin.Api.Controllers
}
}
+ // Copy params from posted body
+ // TODO clean up when breaking API compatibility.
+ userId ??= playbackInfoDto?.UserId;
+ maxStreamingBitrate ??= playbackInfoDto?.MaxStreamingBitrate;
+ startTimeTicks ??= playbackInfoDto?.StartTimeTicks;
+ audioStreamIndex ??= playbackInfoDto?.AudioStreamIndex;
+ subtitleStreamIndex ??= playbackInfoDto?.SubtitleStreamIndex;
+ maxAudioChannels ??= playbackInfoDto?.MaxAudioChannels;
+ mediaSourceId ??= playbackInfoDto?.MediaSourceId;
+ liveStreamId ??= playbackInfoDto?.LiveStreamId;
+ autoOpenLiveStream ??= playbackInfoDto?.AutoOpenLiveStream ?? false;
+ enableDirectPlay ??= playbackInfoDto?.EnableDirectPlay ?? true;
+ enableDirectStream ??= playbackInfoDto?.EnableDirectStream ?? true;
+ enableTranscoding ??= playbackInfoDto?.EnableTranscoding ?? true;
+ allowVideoStreamCopy ??= playbackInfoDto?.AllowVideoStreamCopy ?? true;
+ allowAudioStreamCopy ??= playbackInfoDto?.AllowAudioStreamCopy ?? true;
+
var info = await _mediaInfoHelper.GetPlaybackInfo(
itemId,
userId,
@@ -161,18 +179,18 @@ namespace Jellyfin.Api.Controllers
maxAudioChannels,
info!.PlaySessionId!,
userId ?? Guid.Empty,
- enableDirectPlay,
- enableDirectStream,
- enableTranscoding,
- allowVideoStreamCopy,
- allowAudioStreamCopy,
+ enableDirectPlay.Value,
+ enableDirectStream.Value,
+ enableTranscoding.Value,
+ allowVideoStreamCopy.Value,
+ allowAudioStreamCopy.Value,
Request.HttpContext.GetNormalizedRemoteIp());
}
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
}
- if (autoOpenLiveStream)
+ if (autoOpenLiveStream.Value)
{
var mediaSource = string.IsNullOrWhiteSpace(mediaSourceId) ? info.MediaSources[0] : info.MediaSources.FirstOrDefault(i => string.Equals(i.Id, mediaSourceId, StringComparison.Ordinal));
@@ -183,9 +201,9 @@ namespace Jellyfin.Api.Controllers
new LiveStreamRequest
{
AudioStreamIndex = audioStreamIndex,
- DeviceProfile = deviceProfile?.DeviceProfile,
- EnableDirectPlay = enableDirectPlay,
- EnableDirectStream = enableDirectStream,
+ DeviceProfile = playbackInfoDto?.DeviceProfile,
+ EnableDirectPlay = enableDirectPlay.Value,
+ EnableDirectStream = enableDirectStream.Value,
ItemId = itemId,
MaxAudioChannels = maxAudioChannels,
MaxStreamingBitrate = maxStreamingBitrate,
diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs
index ebc148fe5..4d788ad7d 100644
--- a/Jellyfin.Api/Controllers/MoviesController.cs
+++ b/Jellyfin.Api/Controllers/MoviesController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -65,7 +65,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Recommendations")]
public ActionResult<IEnumerable<RecommendationDto>> GetMovieRecommendations(
[FromQuery] Guid? userId,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] int categoryLimit = 5,
[FromQuery] int itemLimit = 8)
@@ -78,7 +78,7 @@ namespace Jellyfin.Api.Controllers
var categories = new List<RecommendationDto>();
- var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+ var parentIdGuid = parentId ?? Guid.Empty;
var query = new InternalItemsQuery(user)
{
diff --git a/Jellyfin.Api/Controllers/MusicGenresController.cs b/Jellyfin.Api/Controllers/MusicGenresController.cs
index 229d9ff02..2608a9cd0 100644
--- a/Jellyfin.Api/Controllers/MusicGenresController.cs
+++ b/Jellyfin.Api/Controllers/MusicGenresController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
@@ -72,10 +72,10 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isFavorite,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -96,8 +96,8 @@ namespace Jellyfin.Api.Controllers
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
@@ -109,21 +109,21 @@ namespace Jellyfin.Api.Controllers
EnableTotalRecordCount = enableTotalRecordCount
};
- if (!string.IsNullOrWhiteSpace(parentId))
+ if (parentId.HasValue)
{
if (parentItem is Folder)
{
- query.AncestorIds = new[] { new Guid(parentId) };
+ query.AncestorIds = new[] { parentId.Value };
}
else
{
- query.ItemIds = new[] { new Guid(parentId) };
+ query.ItemIds = new[] { parentId.Value };
}
}
var result = _libraryManager.GetMusicGenres(query);
- var shouldIncludeItemTypes = !string.IsNullOrWhiteSpace(includeItemTypes);
+ var shouldIncludeItemTypes = includeItemTypes.Length != 0;
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs
index 1f797d6bc..6295dfc05 100644
--- a/Jellyfin.Api/Controllers/PackageController.cs
+++ b/Jellyfin.Api/Controllers/PackageController.cs
@@ -45,13 +45,13 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<PackageInfo>> GetPackageInfo(
[FromRoute, Required] string name,
- [FromQuery] string? assemblyGuid)
+ [FromQuery] Guid? assemblyGuid)
{
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
var result = _installationManager.FilterPackages(
packages,
name,
- string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid))
+ assemblyGuid ?? default)
.FirstOrDefault();
if (result == null)
@@ -92,21 +92,21 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
public async Task<ActionResult> InstallPackage(
[FromRoute, Required] string name,
- [FromQuery] string? assemblyGuid,
+ [FromQuery] Guid? assemblyGuid,
[FromQuery] string? version,
[FromQuery] string? repositoryUrl)
{
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
if (!string.IsNullOrEmpty(repositoryUrl))
{
- packages = packages.Where(p => p.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase))
+ packages = packages.Where(p => p.versions.Where(q => q.repositoryUrl.Equals(repositoryUrl, StringComparison.OrdinalIgnoreCase)).Any())
.ToList();
}
var package = _installationManager.GetCompatibleVersions(
packages,
name,
- string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid),
+ assemblyGuid ?? Guid.Empty,
specificVersion: string.IsNullOrEmpty(version) ? null : Version.Parse(version))
.FirstOrDefault();
diff --git a/Jellyfin.Api/Controllers/PersonsController.cs b/Jellyfin.Api/Controllers/PersonsController.cs
index 6ac3e6417..17e631197 100644
--- a/Jellyfin.Api/Controllers/PersonsController.cs
+++ b/Jellyfin.Api/Controllers/PersonsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
@@ -77,9 +77,9 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
- [FromQuery] string? excludePersonTypes,
- [FromQuery] string? personTypes,
- [FromQuery] string? appearsInItemId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludePersonTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+ [FromQuery] Guid? appearsInItemId,
[FromQuery] Guid? userId,
[FromQuery] bool? enableImages = true)
{
@@ -97,12 +97,12 @@ namespace Jellyfin.Api.Controllers
var isFavoriteInFilters = filters.Any(f => f == ItemFilter.IsFavorite);
var peopleItems = _libraryManager.GetPeopleItems(new InternalPeopleQuery
{
- PersonTypes = RequestHelpers.Split(personTypes, ',', true),
- ExcludePersonTypes = RequestHelpers.Split(excludePersonTypes, ',', true),
+ PersonTypes = personTypes,
+ ExcludePersonTypes = excludePersonTypes,
NameContains = searchTerm,
User = user,
IsFavorite = !isFavorite.HasValue && isFavoriteInFilters ? true : isFavorite,
- AppearsInItemId = string.IsNullOrEmpty(appearsInItemId) ? Guid.Empty : Guid.Parse(appearsInItemId),
+ AppearsInItemId = appearsInItemId ?? Guid.Empty,
Limit = limit ?? 0
});
diff --git a/Jellyfin.Api/Controllers/PlaylistsController.cs b/Jellyfin.Api/Controllers/PlaylistsController.cs
index 4b3d8d3d3..3e55434c0 100644
--- a/Jellyfin.Api/Controllers/PlaylistsController.cs
+++ b/Jellyfin.Api/Controllers/PlaylistsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@@ -63,11 +63,10 @@ namespace Jellyfin.Api.Controllers
public async Task<ActionResult<PlaylistCreationResult>> CreatePlaylist(
[FromBody, Required] CreatePlaylistDto createPlaylistRequest)
{
- Guid[] idGuidArray = RequestHelpers.GetGuids(createPlaylistRequest.Ids);
var result = await _playlistManager.CreatePlaylist(new PlaylistCreationRequest
{
Name = createPlaylistRequest.Name,
- ItemIdList = idGuidArray,
+ ItemIdList = createPlaylistRequest.Ids,
UserId = createPlaylistRequest.UserId,
MediaType = createPlaylistRequest.MediaType
}).ConfigureAwait(false);
@@ -87,10 +86,10 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> AddToPlaylist(
[FromRoute, Required] Guid playlistId,
- [FromQuery] string? ids,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
[FromQuery] Guid? userId)
{
- await _playlistManager.AddToPlaylistAsync(playlistId, RequestHelpers.GetGuids(ids), userId ?? Guid.Empty).ConfigureAwait(false);
+ await _playlistManager.AddToPlaylistAsync(playlistId, ids, userId ?? Guid.Empty).ConfigureAwait(false);
return NoContent();
}
@@ -122,9 +121,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>An <see cref="NoContentResult"/> on success.</returns>
[HttpDelete("{playlistId}/Items")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public async Task<ActionResult> RemoveFromPlaylist([FromRoute, Required] string playlistId, [FromQuery] string? entryIds)
+ public async Task<ActionResult> RemoveFromPlaylist(
+ [FromRoute, Required] string playlistId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] entryIds)
{
- await _playlistManager.RemoveFromPlaylistAsync(playlistId, RequestHelpers.Split(entryIds, ',', true)).ConfigureAwait(false);
+ await _playlistManager.RemoveFromPlaylistAsync(playlistId, entryIds).ConfigureAwait(false);
return NoContent();
}
diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs
index 5c15e9a0d..ec7b84ff6 100644
--- a/Jellyfin.Api/Controllers/PlaystateController.cs
+++ b/Jellyfin.Api/Controllers/PlaystateController.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
@@ -74,7 +75,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult<UserItemDataDto> MarkPlayedItem(
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId,
- [FromQuery] DateTime? datePlayed)
+ [FromQuery, ModelBinder(typeof(LegacyDateTimeModelBinder))] DateTime? datePlayed)
{
var user = _userManager.GetUserById(userId);
var session = RequestHelpers.GetSession(_sessionManager, _authContext, Request);
diff --git a/Jellyfin.Api/Controllers/PluginsController.cs b/Jellyfin.Api/Controllers/PluginsController.cs
index 0f8ceba29..98f1bc2d2 100644
--- a/Jellyfin.Api/Controllers/PluginsController.cs
+++ b/Jellyfin.Api/Controllers/PluginsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs
index e75f0d06b..08255ff8f 100644
--- a/Jellyfin.Api/Controllers/SearchController.cs
+++ b/Jellyfin.Api/Controllers/SearchController.cs
@@ -5,6 +5,7 @@ using System.Globalization;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -82,10 +83,10 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit,
[FromQuery] Guid? userId,
[FromQuery, Required] string searchTerm,
- [FromQuery] string? includeItemTypes,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? mediaTypes,
- [FromQuery] string? parentId,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
+ [FromQuery] Guid? parentId,
[FromQuery] bool? isMovie,
[FromQuery] bool? isSeries,
[FromQuery] bool? isNews,
@@ -108,9 +109,9 @@ namespace Jellyfin.Api.Controllers
IncludeStudios = includeStudios,
StartIndex = startIndex,
UserId = userId ?? Guid.Empty,
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
- ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true),
- MediaTypes = RequestHelpers.Split(mediaTypes, ',', true),
+ IncludeItemTypes = includeItemTypes,
+ ExcludeItemTypes = excludeItemTypes,
+ MediaTypes = mediaTypes,
ParentId = parentId,
IsKids = isKids,
diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs
index e506ac7bf..e2269a2ce 100644
--- a/Jellyfin.Api/Controllers/SessionController.cs
+++ b/Jellyfin.Api/Controllers/SessionController.cs
@@ -6,6 +6,7 @@ using System.Threading;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
+using Jellyfin.Api.Models.SessionDtos;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
@@ -160,12 +161,12 @@ namespace Jellyfin.Api.Controllers
public ActionResult Play(
[FromRoute, Required] string sessionId,
[FromQuery, Required] PlayCommand playCommand,
- [FromQuery, Required] string itemIds,
+ [FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] itemIds,
[FromQuery] long? startPositionTicks)
{
var playRequest = new PlayRequest
{
- ItemIds = RequestHelpers.GetGuids(itemIds),
+ ItemIds = itemIds,
StartPositionTicks = startPositionTicks,
PlayCommand = playCommand
};
@@ -378,7 +379,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostCapabilities(
[FromQuery] string? id,
- [FromQuery] string? playableMediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] playableMediaTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] GeneralCommandType[] supportedCommands,
[FromQuery] bool supportsMediaControl = false,
[FromQuery] bool supportsSync = false,
@@ -391,7 +392,7 @@ namespace Jellyfin.Api.Controllers
_sessionManager.ReportCapabilities(id, new ClientCapabilities
{
- PlayableMediaTypes = RequestHelpers.Split(playableMediaTypes, ',', true),
+ PlayableMediaTypes = playableMediaTypes,
SupportedCommands = supportedCommands,
SupportsMediaControl = supportsMediaControl,
SupportsSync = supportsSync,
@@ -412,14 +413,14 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult PostFullCapabilities(
[FromQuery] string? id,
- [FromBody, Required] ClientCapabilities capabilities)
+ [FromBody, Required] ClientCapabilitiesDto capabilities)
{
if (string.IsNullOrWhiteSpace(id))
{
id = RequestHelpers.GetSession(_sessionManager, _authContext, Request).Id;
}
- _sessionManager.ReportCapabilities(id, capabilities);
+ _sessionManager.ReportCapabilities(id, capabilities.ToClientCapabilities());
return NoContent();
}
diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs
index 9c259cc19..d9cb34557 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -3,6 +3,7 @@ using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.StartupDtos;
+using Jellyfin.Networking.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
@@ -72,9 +73,9 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
{
- _config.Configuration.UICulture = startupConfiguration.UICulture;
- _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode;
- _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage;
+ _config.Configuration.UICulture = startupConfiguration.UICulture ?? string.Empty;
+ _config.Configuration.MetadataCountryCode = startupConfiguration.MetadataCountryCode ?? string.Empty;
+ _config.Configuration.PreferredMetadataLanguage = startupConfiguration.PreferredMetadataLanguage ?? string.Empty;
_config.SaveConfiguration();
return NoContent();
}
@@ -89,9 +90,10 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
{
- _config.Configuration.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
- _config.Configuration.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping;
- _config.SaveConfiguration();
+ NetworkConfiguration settings = _config.GetNetworkConfiguration();
+ settings.EnableRemoteAccess = startupRemoteAccessDto.EnableRemoteAccess;
+ settings.EnableUPnP = startupRemoteAccessDto.EnableAutomaticPortMapping;
+ _config.SaveConfiguration("network", settings);
return NoContent();
}
diff --git a/Jellyfin.Api/Controllers/StudiosController.cs b/Jellyfin.Api/Controllers/StudiosController.cs
index 27dcd51bc..bb54c59f6 100644
--- a/Jellyfin.Api/Controllers/StudiosController.cs
+++ b/Jellyfin.Api/Controllers/StudiosController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
@@ -71,10 +71,10 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? searchTerm,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isFavorite,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
@@ -94,13 +94,10 @@ namespace Jellyfin.Api.Controllers
var parentItem = _libraryManager.GetParentItem(parentId, userId);
- var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
- var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
-
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = excludeItemTypesArr,
- IncludeItemTypes = includeItemTypesArr,
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
StartIndex = startIndex,
Limit = limit,
IsFavorite = isFavorite,
@@ -112,20 +109,20 @@ namespace Jellyfin.Api.Controllers
EnableTotalRecordCount = enableTotalRecordCount
};
- if (!string.IsNullOrWhiteSpace(parentId))
+ if (parentId.HasValue)
{
if (parentItem is Folder)
{
- query.AncestorIds = new[] { new Guid(parentId) };
+ query.AncestorIds = new[] { parentId.Value };
}
else
{
- query.ItemIds = new[] { new Guid(parentId) };
+ query.ItemIds = new[] { parentId.Value };
}
}
var result = _libraryManager.GetStudios(query);
- var shouldIncludeItemTypes = !string.IsNullOrEmpty(includeItemTypes);
+ var shouldIncludeItemTypes = includeItemTypes.Length != 0;
return RequestHelpers.CreateQueryResult(result, dtoOptions, _dtoService, shouldIncludeItemTypes, user);
}
diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs
index a01ae31a0..dcb8e803b 100644
--- a/Jellyfin.Api/Controllers/SubtitleController.cs
+++ b/Jellyfin.Api/Controllers/SubtitleController.cs
@@ -193,7 +193,6 @@ namespace Jellyfin.Api.Controllers
/// <response code="200">File returned.</response>
/// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
[HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/Stream.{format}")]
- [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks?}/Stream.{format}", Name = "GetSubtitle_2")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile("text/*")]
public async Task<ActionResult> GetSubtitle(
@@ -204,7 +203,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] long? endPositionTicks,
[FromQuery] bool copyTimestamps = false,
[FromQuery] bool addVttTimeMap = false,
- [FromRoute] long startPositionTicks = 0)
+ [FromQuery] long startPositionTicks = 0)
{
if (string.Equals(format, "js", StringComparison.OrdinalIgnoreCase))
{
@@ -250,6 +249,43 @@ namespace Jellyfin.Api.Controllers
}
/// <summary>
+ /// Gets subtitles in a specified format.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="mediaSourceId">The media source id.</param>
+ /// <param name="index">The subtitle stream index.</param>
+ /// <param name="startPositionTicks">Optional. The start position of the subtitle in ticks.</param>
+ /// <param name="format">The format of the returned subtitle.</param>
+ /// <param name="endPositionTicks">Optional. The end position of the subtitle in ticks.</param>
+ /// <param name="copyTimestamps">Optional. Whether to copy the timestamps.</param>
+ /// <param name="addVttTimeMap">Optional. Whether to add a VTT time map.</param>
+ /// <response code="200">File returned.</response>
+ /// <returns>A <see cref="FileContentResult"/> with the subtitle file.</returns>
+ [HttpGet("Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/{startPositionTicks}/Stream.{format}")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesFile("text/*")]
+ public Task<ActionResult> GetSubtitleWithTicks(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] string mediaSourceId,
+ [FromRoute, Required] int index,
+ [FromRoute, Required] long startPositionTicks,
+ [FromRoute, Required] string format,
+ [FromQuery] long? endPositionTicks,
+ [FromQuery] bool copyTimestamps = false,
+ [FromQuery] bool addVttTimeMap = false)
+ {
+ return GetSubtitle(
+ itemId,
+ mediaSourceId,
+ index,
+ format,
+ endPositionTicks,
+ copyTimestamps,
+ addVttTimeMap,
+ startPositionTicks);
+ }
+
+ /// <summary>
/// Gets an HLS subtitle playlist.
/// </summary>
/// <param name="itemId">The item id.</param>
@@ -335,6 +371,7 @@ namespace Jellyfin.Api.Controllers
/// <response code="204">Subtitle uploaded.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Videos/{itemId}/Subtitles")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> UploadSubtitle(
[FromRoute, Required] Guid itemId,
[FromBody, Required] UploadSubtitleDto body)
@@ -446,6 +483,7 @@ namespace Jellyfin.Api.Controllers
[HttpGet("FallbackFont/Fonts/{name}")]
[Authorize(Policy = Policies.DefaultAuthorization)]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesFile("font/*")]
public ActionResult GetFallbackFont([FromRoute, Required] string name)
{
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
diff --git a/Jellyfin.Api/Controllers/SuggestionsController.cs b/Jellyfin.Api/Controllers/SuggestionsController.cs
index ad64adfba..9f1dec712 100644
--- a/Jellyfin.Api/Controllers/SuggestionsController.cs
+++ b/Jellyfin.Api/Controllers/SuggestionsController.cs
@@ -1,9 +1,10 @@
-using System;
+using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -58,8 +59,8 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<BaseItemDto>> GetSuggestions(
[FromRoute, Required] Guid userId,
- [FromQuery] string? mediaType,
- [FromQuery] string? type,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaType,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] type,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] bool enableTotalRecordCount = false)
@@ -70,8 +71,8 @@ namespace Jellyfin.Api.Controllers
var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
{
OrderBy = new[] { ItemSortBy.Random }.Select(i => new ValueTuple<string, SortOrder>(i, SortOrder.Descending)).ToArray(),
- MediaTypes = RequestHelpers.Split(mediaType!, ',', true),
- IncludeItemTypes = RequestHelpers.Split(type!, ',', true),
+ MediaTypes = mediaType,
+ IncludeItemTypes = type,
IsVirtualItem = false,
StartIndex = startIndex,
Limit = limit,
diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs
index e16a10ba4..471c9180d 100644
--- a/Jellyfin.Api/Controllers/SyncPlayController.cs
+++ b/Jellyfin.Api/Controllers/SyncPlayController.cs
@@ -1,12 +1,15 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.Models.SyncPlayDtos;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -17,7 +20,7 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// The sync play controller.
/// </summary>
- [Authorize(Policy = Policies.DefaultAuthorization)]
+ [Authorize(Policy = Policies.SyncPlayAccess)]
public class SyncPlayController : BaseJellyfinApiController
{
private readonly ISessionManager _sessionManager;
@@ -43,35 +46,36 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Create a new SyncPlay group.
/// </summary>
+ /// <param name="requestData">The settings of the new group.</param>
/// <response code="204">New group created.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("New")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayCreateGroup()
+ [Authorize(Policy = Policies.SyncPlayCreateGroupAccess)]
+ public ActionResult SyncPlayCreateGroup(
+ [FromBody, Required] NewGroupRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- _syncPlayManager.NewGroup(currentSession, CancellationToken.None);
+ var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
+ _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
/// <summary>
/// Join an existing SyncPlay group.
/// </summary>
- /// <param name="groupId">The sync play group id.</param>
+ /// <param name="requestData">The group to join.</param>
/// <response code="204">Group join successful.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Join")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId)
+ [Authorize(Policy = Policies.SyncPlayAccess)]
+ public ActionResult SyncPlayJoinGroup(
+ [FromBody, Required] JoinGroupRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
-
- var joinRequest = new JoinGroupRequest()
- {
- GroupId = groupId
- };
-
- _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None);
+ var syncPlayRequest = new JoinGroupRequest(requestData.GroupId);
+ _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
@@ -85,38 +89,125 @@ namespace Jellyfin.Api.Controllers
public ActionResult SyncPlayLeaveGroup()
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None);
+ var syncPlayRequest = new LeaveGroupRequest();
+ _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
/// <summary>
/// Gets all SyncPlay groups.
/// </summary>
- /// <param name="filterItemId">Optional. Filter by item id.</param>
/// <response code="200">Groups returned.</response>
/// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns>
[HttpGet("List")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult<IEnumerable<GroupInfoView>> SyncPlayGetGroups([FromQuery] Guid? filterItemId)
+ [Authorize(Policy = Policies.SyncPlayAccess)]
+ public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups()
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new ListGroupsRequest();
+ return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest));
+ }
+
+ /// <summary>
+ /// Request to set new playlist in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The new playlist to play in the group.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("SetNewQueue")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlaySetNewQueue(
+ [FromBody, Required] PlayRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty));
+ var syncPlayRequest = new PlayGroupRequest(
+ requestData.PlayingQueue,
+ requestData.PlayingItemPosition,
+ requestData.StartPositionTicks);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
}
/// <summary>
- /// Request play in SyncPlay group.
+ /// Request to change playlist item in SyncPlay group.
/// </summary>
- /// <response code="204">Play request sent to all group members.</response>
+ /// <param name="requestData">The new item to play.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
- [HttpPost("Play")]
+ [HttpPost("SetPlaylistItem")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayPlay()
+ public ActionResult SyncPlaySetPlaylistItem(
+ [FromBody, Required] SetPlaylistItemRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = PlaybackRequestType.Play
- };
+ var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to remove items from the playlist in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The items to remove.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("RemoveFromPlaylist")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayRemoveFromPlaylist(
+ [FromBody, Required] RemoveFromPlaylistRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to move an item in the playlist in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The new position for the item.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("MovePlaylistItem")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayMovePlaylistItem(
+ [FromBody, Required] MovePlaylistItemRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to queue items to the playlist of a SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The items to add.</param>
+ /// <response code="204">Queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("Queue")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayQueue(
+ [FromBody, Required] QueueRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request unpause in SyncPlay group.
+ /// </summary>
+ /// <response code="204">Unpause update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("Unpause")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayUnpause()
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new UnpauseGroupRequest();
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
@@ -124,17 +215,29 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Request pause in SyncPlay group.
/// </summary>
- /// <response code="204">Pause request sent to all group members.</response>
+ /// <response code="204">Pause update sent to all group members.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Pause")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult SyncPlayPause()
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = PlaybackRequestType.Pause
- };
+ var syncPlayRequest = new PauseGroupRequest();
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request stop in SyncPlay group.
+ /// </summary>
+ /// <response code="204">Stop update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("Stop")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayStop()
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new StopGroupRequest();
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
@@ -142,42 +245,143 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Request seek in SyncPlay group.
/// </summary>
- /// <param name="positionTicks">The playback position in ticks.</param>
- /// <response code="204">Seek request sent to all group members.</response>
+ /// <param name="requestData">The new playback position.</param>
+ /// <response code="204">Seek update sent to all group members.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Seek")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlaySeek([FromQuery] long positionTicks)
+ public ActionResult SyncPlaySeek(
+ [FromBody, Required] SeekRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = PlaybackRequestType.Seek,
- PositionTicks = positionTicks
- };
+ var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks);
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
/// <summary>
- /// Request group wait in SyncPlay group while buffering.
+ /// Notify SyncPlay group that member is buffering.
/// </summary>
- /// <param name="when">When the request has been made by the client.</param>
- /// <param name="positionTicks">The playback position in ticks.</param>
- /// <param name="bufferingDone">Whether the buffering is done.</param>
- /// <response code="204">Buffering request sent to all group members.</response>
+ /// <param name="requestData">The player status.</param>
+ /// <response code="204">Group state update sent to all group members.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Buffering")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayBuffering([FromQuery] DateTime when, [FromQuery] long positionTicks, [FromQuery] bool bufferingDone)
+ public ActionResult SyncPlayBuffering(
+ [FromBody, Required] BufferRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new BufferGroupRequest(
+ requestData.When,
+ requestData.PositionTicks,
+ requestData.IsPlaying,
+ requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Notify SyncPlay group that member is ready for playback.
+ /// </summary>
+ /// <param name="requestData">The player status.</param>
+ /// <response code="204">Group state update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("Ready")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayReady(
+ [FromBody, Required] ReadyRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new ReadyGroupRequest(
+ requestData.When,
+ requestData.PositionTicks,
+ requestData.IsPlaying,
+ requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request SyncPlay group to ignore member during group-wait.
+ /// </summary>
+ /// <param name="requestData">The settings to set.</param>
+ /// <response code="204">Member state updated.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("SetIgnoreWait")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlaySetIgnoreWait(
+ [FromBody, Required] IgnoreWaitRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request next item in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The current item information.</param>
+ /// <response code="204">Next item update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("NextItem")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayNextItem(
+ [FromBody, Required] NextItemRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request previous item in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The current item information.</param>
+ /// <response code="204">Previous item update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("PreviousItem")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlayPreviousItem(
+ [FromBody, Required] PreviousItemRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to set repeat mode in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The new repeat mode.</param>
+ /// <response code="204">Play queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("SetRepeatMode")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlaySetRepeatMode(
+ [FromBody, Required] SetRepeatModeRequestDto requestData)
+ {
+ var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
+ var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode);
+ _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+ return NoContent();
+ }
+
+ /// <summary>
+ /// Request to set shuffle mode in SyncPlay group.
+ /// </summary>
+ /// <param name="requestData">The new shuffle mode.</param>
+ /// <response code="204">Play queue update sent to all group members.</response>
+ /// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
+ [HttpPost("SetShuffleMode")]
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
+ public ActionResult SyncPlaySetShuffleMode(
+ [FromBody, Required] SetShuffleModeRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer,
- When = when,
- PositionTicks = positionTicks
- };
+ var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode);
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
@@ -185,19 +389,16 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Update session ping.
/// </summary>
- /// <param name="ping">The ping.</param>
+ /// <param name="requestData">The new ping.</param>
/// <response code="204">Ping updated.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Ping")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
- public ActionResult SyncPlayPing([FromQuery] double ping)
+ public ActionResult SyncPlayPing(
+ [FromBody, Required] PingRequestDto requestData)
{
var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request);
- var syncPlayRequest = new PlaybackRequest()
- {
- Type = PlaybackRequestType.Ping,
- Ping = Convert.ToInt64(ping)
- };
+ var syncPlayRequest = new PingGroupRequest(requestData.Ping);
_syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
return NoContent();
}
diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs
index 4cb1984a2..7784e8a11 100644
--- a/Jellyfin.Api/Controllers/SystemController.cs
+++ b/Jellyfin.Api/Controllers/SystemController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
@@ -64,9 +64,9 @@ namespace Jellyfin.Api.Controllers
[HttpGet("Info")]
[Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<SystemInfo>> GetSystemInfo()
+ public ActionResult<SystemInfo> GetSystemInfo()
{
- return await _appHost.GetSystemInfo(CancellationToken.None).ConfigureAwait(false);
+ return _appHost.GetSystemInfo(Request.HttpContext.Connection.RemoteIpAddress);
}
/// <summary>
@@ -76,9 +76,9 @@ namespace Jellyfin.Api.Controllers
/// <returns>A <see cref="PublicSystemInfo"/> with public info about the system.</returns>
[HttpGet("Info/Public")]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task<ActionResult<PublicSystemInfo>> GetPublicSystemInfo()
+ public ActionResult<PublicSystemInfo> GetPublicSystemInfo()
{
- return await _appHost.GetPublicSystemInfo(CancellationToken.None).ConfigureAwait(false);
+ return _appHost.GetPublicSystemInfo(Request.HttpContext.Connection.RemoteIpAddress);
}
/// <summary>
diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs
index 2dc744e7c..c730ac12b 100644
--- a/Jellyfin.Api/Controllers/TimeSyncController.cs
+++ b/Jellyfin.Api/Controllers/TimeSyncController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Globalization;
using MediaBrowser.Model.SyncPlay;
using Microsoft.AspNetCore.Http;
@@ -13,7 +13,7 @@ namespace Jellyfin.Api.Controllers
public class TimeSyncController : BaseJellyfinApiController
{
/// <summary>
- /// Gets the current utc time.
+ /// Gets the current UTC time.
/// </summary>
/// <response code="200">Time returned.</response>
/// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns>
@@ -22,18 +22,14 @@ namespace Jellyfin.Api.Controllers
public ActionResult<UtcTimeResponse> GetUtcTime()
{
// Important to keep the following line at the beginning
- var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo);
+ var requestReceptionTime = DateTime.UtcNow.ToUniversalTime();
- var response = new UtcTimeResponse();
- response.RequestReceptionTime = requestReceptionTime;
-
- // Important to keep the following two lines at the end
- var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo);
- response.ResponseTransmissionTime = responseTransmissionTime;
+ // Important to keep the following line at the end
+ var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime();
// Implementing NTP on such a high level results in this useless
// information being sent. On the other hand it enables future additions.
- return response;
+ return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime);
}
}
}
diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs
index d78adcbcd..8e9ece14f 100644
--- a/Jellyfin.Api/Controllers/TrailersController.cs
+++ b/Jellyfin.Api/Controllers/TrailersController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using Jellyfin.Api.Constants;
using Jellyfin.Api.ModelBinders;
using MediaBrowser.Model.Dto;
@@ -42,8 +42,8 @@ namespace Jellyfin.Api.Controllers
/// <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>
/// <param name="is4K">Optional filter by items that are 4K or not.</param>
- /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimeted.</param>
- /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimeted.</param>
+ /// <param name="locationTypes">Optional. If specified, results will be filtered based on LocationType. This allows multiple, comma delimited.</param>
+ /// <param name="excludeLocationTypes">Optional. If specified, results will be filtered based on the LocationType. This allows multiple, comma delimited.</param>
/// <param name="isMissing">Optional filter by items that are missing episodes or not.</param>
/// <param name="isUnaired">Optional filter by items that are unaired episodes or not.</param>
/// <param name="minCommunityRating">Optional filter by minimum community rating.</param>
@@ -56,41 +56,41 @@ namespace Jellyfin.Api.Controllers
/// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param>
/// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param>
/// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param>
- /// <param name="excludeItemIds">Optional. If specified, results will be filtered by exxcluding item ids. This allows multiple, comma delimeted.</param>
+ /// <param name="excludeItemIds">Optional. If specified, results will be filtered by excluding item ids. This allows multiple, comma delimited.</param>
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
/// <param name="limit">Optional. The maximum number of records to return.</param>
/// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param>
/// <param name="searchTerm">Optional. Filter based on a search term.</param>
/// <param name="sortOrder">Sort Order - Ascending,Descending.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
- /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
- /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimeted. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param>
+ /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
+ /// <param name="filters">Optional. Specify additional filters to apply. This allows multiple, comma delimited. Options: IsFolder, IsNotFolder, IsUnplayed, IsPlayed, IsFavorite, IsResumable, Likes, Dislikes.</param>
/// <param name="isFavorite">Optional filter by items that are marked as favorite, or not.</param>
/// <param name="mediaTypes">Optional filter by MediaType. Allows multiple, comma delimited.</param>
/// <param name="imageTypes">Optional. If specified, results will be filtered based on those containing image types. This allows multiple, comma delimited.</param>
- /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</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="isPlayed">Optional filter by items that are played, or not.</param>
- /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimeted.</param>
- /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimeted.</param>
- /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimeted.</param>
- /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimeted.</param>
+ /// <param name="genres">Optional. If specified, results will be filtered based on genre. This allows multiple, pipe delimited.</param>
+ /// <param name="officialRatings">Optional. If specified, results will be filtered based on OfficialRating. This allows multiple, pipe delimited.</param>
+ /// <param name="tags">Optional. If specified, results will be filtered based on tag. This allows multiple, pipe delimited.</param>
+ /// <param name="years">Optional. If specified, results will be filtered based on production year. This allows multiple, comma delimited.</param>
/// <param name="enableUserData">Optional, include user data.</param>
/// <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>
/// <param name="person">Optional. If specified, results will be filtered to include only those containing the specified person.</param>
/// <param name="personIds">Optional. If specified, results will be filtered to include only those containing the specified person id.</param>
/// <param name="personTypes">Optional. If specified, along with Person, results will be filtered to include only those containing the specified person and PersonType. Allows multiple, comma-delimited.</param>
- /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimeted.</param>
- /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimeted.</param>
- /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimeted.</param>
+ /// <param name="studios">Optional. If specified, results will be filtered based on studio. This allows multiple, pipe delimited.</param>
+ /// <param name="artists">Optional. If specified, results will be filtered based on artists. This allows multiple, pipe delimited.</param>
+ /// <param name="excludeArtistIds">Optional. If specified, results will be filtered based on artist id. This allows multiple, pipe delimited.</param>
/// <param name="artistIds">Optional. If specified, results will be filtered to include only those containing the specified artist id.</param>
/// <param name="albumArtistIds">Optional. If specified, results will be filtered to include only those containing the specified album artist id.</param>
/// <param name="contributingArtistIds">Optional. If specified, results will be filtered to include only those containing the specified contributing artist id.</param>
- /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimeted.</param>
- /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimeted.</param>
+ /// <param name="albums">Optional. If specified, results will be filtered based on album. This allows multiple, pipe delimited.</param>
+ /// <param name="albumIds">Optional. If specified, results will be filtered based on album id. This allows multiple, pipe delimited.</param>
/// <param name="ids">Optional. If specific items are needed, specify a list of item id's to retrieve. This allows multiple, comma delimited.</param>
- /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimeted.</param>
+ /// <param name="videoTypes">Optional filter by VideoType (videofile, dvd, bluray, iso). Allows multiple, comma delimited.</param>
/// <param name="minOfficialRating">Optional filter by minimum official rating (PG, PG-13, TV-MA, etc).</param>
/// <param name="isLocked">Optional filter by items that are locked.</param>
/// <param name="isPlaceHolder">Optional filter by items that are placeholders.</param>
@@ -101,12 +101,12 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxWidth">Optional. Filter by the maximum width of the item.</param>
/// <param name="maxHeight">Optional. Filter by the maximum height of the item.</param>
/// <param name="is3D">Optional filter by items that are 3D, or not.</param>
- /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimeted.</param>
+ /// <param name="seriesStatus">Optional filter by Series Status. Allows multiple, comma delimited.</param>
/// <param name="nameStartsWithOrGreater">Optional filter by items whose name is sorted equally or greater than a given input string.</param>
/// <param name="nameStartsWith">Optional filter by items whose name is sorted equally than a given input string.</param>
/// <param name="nameLessThan">Optional filter by items whose name is equally or lesser than a given input string.</param>
- /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimeted.</param>
- /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimeted.</param>
+ /// <param name="studioIds">Optional. If specified, results will be filtered based on studio id. This allows multiple, pipe delimited.</param>
+ /// <param name="genreIds">Optional. If specified, results will be filtered based on genre id. This allows multiple, pipe delimited.</param>
/// <param name="enableTotalRecordCount">Optional. Enable the total record count.</param>
/// <param name="enableImages">Optional, include image information in output.</param>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
@@ -125,7 +125,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasParentalRating,
[FromQuery] bool? isHd,
[FromQuery] bool? is4K,
- [FromQuery] string? locationTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] locationTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] LocationType[] excludeLocationTypes,
[FromQuery] bool? isMissing,
[FromQuery] bool? isUnaired,
@@ -139,41 +139,41 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? hasImdbId,
[FromQuery] bool? hasTmdbId,
[FromQuery] bool? hasTvdbId,
- [FromQuery] string? excludeItemIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeItemIds,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] bool? recursive,
[FromQuery] string? searchTerm,
[FromQuery] string? sortOrder,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFilter[] filters,
[FromQuery] bool? isFavorite,
- [FromQuery] string? mediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] imageTypes,
[FromQuery] string? sortBy,
[FromQuery] bool? isPlayed,
- [FromQuery] string? genres,
- [FromQuery] string? officialRatings,
- [FromQuery] string? tags,
- [FromQuery] string? years,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] genres,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] officialRatings,
+ [FromQuery, ModelBinder(typeof(PipeDelimitedArrayModelBinder))] string[] tags,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] int[] years,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
[FromQuery] string? person,
- [FromQuery] string? personIds,
- [FromQuery] string? personTypes,
- [FromQuery] string? studios,
- [FromQuery] string? artists,
- [FromQuery] string? excludeArtistIds,
- [FromQuery] string? artistIds,
- [FromQuery] string? albumArtistIds,
- [FromQuery] string? contributingArtistIds,
- [FromQuery] string? albums,
- [FromQuery] string? albumIds,
- [FromQuery] string? ids,
- [FromQuery] string? videoTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] personIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] personTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] studios,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] artists,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] excludeArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] artistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] contributingArtistIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] albums,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] albumIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] VideoType[] videoTypes,
[FromQuery] string? minOfficialRating,
[FromQuery] bool? isLocked,
[FromQuery] bool? isPlaceHolder,
@@ -184,21 +184,20 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? maxWidth,
[FromQuery] int? maxHeight,
[FromQuery] bool? is3D,
- [FromQuery] string? seriesStatus,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] SeriesStatus[] seriesStatus,
[FromQuery] string? nameStartsWithOrGreater,
[FromQuery] string? nameStartsWith,
[FromQuery] string? nameLessThan,
- [FromQuery] string? studioIds,
- [FromQuery] string? genreIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] studioIds,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] genreIds,
[FromQuery] bool enableTotalRecordCount = true,
[FromQuery] bool? enableImages = true)
{
- var includeItemTypes = "Trailer";
+ var includeItemTypes = new[] { "Trailer" };
return _itemsController
.GetItems(
userId,
- userId,
maxOfficialRating,
hasThemeSong,
hasThemeVideo,
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index 6fd154836..03fd1846d 100644
--- a/Jellyfin.Api/Controllers/TvShowsController.cs
+++ b/Jellyfin.Api/Controllers/TvShowsController.cs
@@ -76,7 +76,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] string? seriesId,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery] bool? enableImges,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -132,7 +132,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery] bool? enableImges,
[FromQuery] int? imageTypeLimit,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ImageType[] enableImageTypes,
@@ -144,7 +144,7 @@ namespace Jellyfin.Api.Controllers
var minPremiereDate = DateTime.Now.Date.ToUniversalTime().AddDays(-1);
- var parentIdGuid = string.IsNullOrWhiteSpace(parentId) ? Guid.Empty : new Guid(parentId);
+ var parentIdGuid = parentId ?? Guid.Empty;
var options = new DtoOptions { Fields = fields }
.AddClientFields(Request)
@@ -176,7 +176,7 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="seriesId">The series id.</param>
/// <param name="userId">The user id.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
/// <param name="season">Optional filter by season number.</param>
/// <param name="seasonId">Optional. Filter by season id.</param>
/// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
@@ -188,20 +188,20 @@ namespace Jellyfin.Api.Controllers
/// <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>
/// <param name="enableUserData">Optional. Include user data.</param>
- /// <param name="sortBy">Optional. Specify one or more sort orders, comma delimeted. Options: Album, AlbumArtist, Artist, Budget, CommunityRating, CriticRating, DateCreated, DatePlayed, PlayCount, PremiereDate, ProductionYear, SortName, Random, Revenue, Runtime.</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>
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the episodes on success or a <see cref="NotFoundResult"/> if the series was not found.</returns>
[HttpGet("{seriesId}/Episodes")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetEpisodes(
- [FromRoute, Required] string seriesId,
+ [FromRoute, Required] Guid seriesId,
[FromQuery] Guid? userId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] int? season,
- [FromQuery] string? seasonId,
+ [FromQuery] Guid? seasonId,
[FromQuery] bool? isMissing,
[FromQuery] string? adjacentTo,
- [FromQuery] string? startItemId,
+ [FromQuery] Guid? startItemId,
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] bool? enableImages,
@@ -220,9 +220,9 @@ namespace Jellyfin.Api.Controllers
.AddClientFields(Request)
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes!);
- if (!string.IsNullOrWhiteSpace(seasonId)) // Season id was supplied. Get episodes by season id.
+ if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
{
- var item = _libraryManager.GetItemById(new Guid(seasonId));
+ var item = _libraryManager.GetItemById(seasonId.Value);
if (!(item is Season seasonItem))
{
return NotFound("No season exists with Id " + seasonId);
@@ -264,10 +264,10 @@ namespace Jellyfin.Api.Controllers
.ToList();
}
- if (!string.IsNullOrWhiteSpace(startItemId))
+ if (startItemId.HasValue)
{
episodes = episodes
- .SkipWhile(i => !string.Equals(i.Id.ToString("N", CultureInfo.InvariantCulture), startItemId, StringComparison.OrdinalIgnoreCase))
+ .SkipWhile(i => startItemId.Value.Equals(i.Id))
.ToList();
}
@@ -303,7 +303,7 @@ namespace Jellyfin.Api.Controllers
/// </summary>
/// <param name="seriesId">The series id.</param>
/// <param name="userId">The user id.</param>
- /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimeted. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
+ /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines, TrailerUrls.</param>
/// <param name="isSpecialSeason">Optional. Filter by special season.</param>
/// <param name="isMissing">Optional. Filter by items that are missing episodes or not.</param>
/// <param name="adjacentTo">Optional. Return items that are siblings of a supplied item.</param>
@@ -316,7 +316,7 @@ namespace Jellyfin.Api.Controllers
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<QueryResult<BaseItemDto>> GetSeasons(
- [FromRoute, Required] string seriesId,
+ [FromRoute, Required] Guid seriesId,
[FromQuery] Guid? userId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
[FromQuery] bool? isSpecialSeason,
diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs
index e10f1fe91..34c9f32fa 100644
--- a/Jellyfin.Api/Controllers/UniversalAudioController.cs
+++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Devices;
@@ -96,7 +97,7 @@ namespace Jellyfin.Api.Controllers
[ProducesAudioFile]
public async Task<ActionResult> GetUniversalAudioStream(
[FromRoute, Required] Guid itemId,
- [FromQuery] string? container,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] container,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] Guid? userId,
@@ -191,8 +192,11 @@ namespace Jellyfin.Api.Controllers
if (!isStatic && string.Equals(mediaSource.TranscodingSubProtocol, "hls", StringComparison.OrdinalIgnoreCase))
{
// hls segment container can only be mpegts or fmp4 per ffmpeg documentation
+ // ffmpeg option -> file extension
+ // mpegts -> ts
+ // fmp4 -> mp4
// TODO: remove this when we switch back to the segment muxer
- var supportedHlsContainers = new[] { "mpegts", "fmp4" };
+ var supportedHlsContainers = new[] { "ts", "mp4" };
var dynamicHlsRequestDto = new HlsAudioRequestDto
{
@@ -201,7 +205,7 @@ namespace Jellyfin.Api.Controllers
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 : "mpegts",
+ SegmentContainer = Array.Exists(supportedHlsContainers, element => element == transcodingContainer) ? transcodingContainer : "ts",
MediaSourceId = mediaSourceId,
DeviceId = deviceId,
AudioCodec = audioCodec,
@@ -258,7 +262,7 @@ namespace Jellyfin.Api.Controllers
}
private DeviceProfile GetDeviceProfile(
- string? container,
+ string[] containers,
string? transcodingContainer,
string? audioCodec,
string? transcodingProtocol,
@@ -270,7 +274,6 @@ namespace Jellyfin.Api.Controllers
{
var deviceProfile = new DeviceProfile();
- var containers = RequestHelpers.Split(container, ',', true);
int len = containers.Length;
var directPlayProfiles = new DirectPlayProfile[len];
for (int i = 0; i < len; i++)
@@ -327,7 +330,7 @@ namespace Jellyfin.Api.Controllers
if (conditions.Count > 0)
{
// codec profile
- codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = container, Conditions = conditions.ToArray() });
+ codecProfiles.Add(new CodecProfile { Type = CodecType.Audio, Container = string.Join(',', containers), Conditions = conditions.ToArray() });
}
deviceProfile.CodecProfiles = codecProfiles.ToArray();
diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs
index ff047c68a..df55b8185 100644
--- a/Jellyfin.Api/Controllers/UserController.cs
+++ b/Jellyfin.Api/Controllers/UserController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index cfd851129..0e65591cc 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@@ -253,7 +253,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="userId">User id.</param>
/// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param>
/// <param name="fields">Optional. Specify additional fields of information to return in the output.</param>
- /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.</param>
+ /// <param name="includeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param>
/// <param name="isPlayed">Filter by items that are played, or not.</param>
/// <param name="enableImages">Optional. include image information in output.</param>
/// <param name="imageTypeLimit">Optional. the max number of images to return, per image type.</param>
@@ -269,7 +269,7 @@ namespace Jellyfin.Api.Controllers
[FromRoute, Required] Guid userId,
[FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
[FromQuery] bool? isPlayed,
[FromQuery] bool? enableImages,
[FromQuery] int? imageTypeLimit,
@@ -296,7 +296,7 @@ namespace Jellyfin.Api.Controllers
new LatestItemsQuery
{
GroupItems = groupItems,
- IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true),
+ IncludeItemTypes = includeItemTypes,
IsPlayed = isPlayed,
Limit = limit,
ParentId = parentId ?? Guid.Empty,
diff --git a/Jellyfin.Api/Controllers/UserViewsController.cs b/Jellyfin.Api/Controllers/UserViewsController.cs
index d575bfc3b..e1483ce9d 100644
--- a/Jellyfin.Api/Controllers/UserViewsController.cs
+++ b/Jellyfin.Api/Controllers/UserViewsController.cs
@@ -1,10 +1,11 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.UserViewDtos;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -67,7 +68,7 @@ namespace Jellyfin.Api.Controllers
public ActionResult<QueryResult<BaseItemDto>> GetUserViews(
[FromRoute, Required] Guid userId,
[FromQuery] bool? includeExternalContent,
- [FromQuery] string? presetViews,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] presetViews,
[FromQuery] bool includeHidden = false)
{
var query = new UserViewQuery
@@ -81,9 +82,9 @@ namespace Jellyfin.Api.Controllers
query.IncludeExternalContent = includeExternalContent.Value;
}
- if (!string.IsNullOrWhiteSpace(presetViews))
+ if (presetViews.Length != 0)
{
- query.PresetViews = RequestHelpers.Split(presetViews, ',', true);
+ query.PresetViews = presetViews;
}
var app = _authContext.GetAuthorizationInfo(Request).Client ?? string.Empty;
diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
index 418c0c123..c2bb0dfff 100644
--- a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
+++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs
@@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Api.Attributes;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
@@ -43,7 +44,7 @@ namespace Jellyfin.Api.Controllers
/// <response code="404">Video or attachment not found.</response>
/// <returns>An <see cref="FileStreamResult"/> containing the attachment stream on success, or a <see cref="NotFoundResult"/> if the attachment could not be found.</returns>
[HttpGet("{videoId}/{mediaSourceId}/Attachments/{index}")]
- [Produces(MediaTypeNames.Application.Octet)]
+ [ProducesFile(MediaTypeNames.Application.Octet)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> GetAttachment(
diff --git a/Jellyfin.Api/Controllers/VideoHlsController.cs b/Jellyfin.Api/Controllers/VideoHlsController.cs
index 389dc8a08..7e743ee0c 100644
--- a/Jellyfin.Api/Controllers/VideoHlsController.cs
+++ b/Jellyfin.Api/Controllers/VideoHlsController.cs
@@ -1,8 +1,9 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.IO;
+using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
@@ -145,14 +146,14 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
- /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
/// <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>
@@ -210,7 +211,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] string? videoCodec,
[FromQuery] string? subtitleCodec,
- [FromQuery] string? transcodingReasons,
+ [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext context,
@@ -265,7 +266,7 @@ namespace Jellyfin.Api.Controllers
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
- TranscodeReasons = transcodingReasons,
+ TranscodeReasons = transcodeReasons,
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context,
@@ -296,23 +297,23 @@ namespace Jellyfin.Api.Controllers
.ConfigureAwait(false);
TranscodingJobDto? job = null;
- var playlist = state.OutputFilePath;
+ var playlistPath = Path.ChangeExtension(state.OutputFilePath, ".m3u8");
- if (!System.IO.File.Exists(playlist))
+ if (!System.IO.File.Exists(playlistPath))
{
- var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlist);
+ var transcodingLock = _transcodingJobHelper.GetTranscodingLock(playlistPath);
await transcodingLock.WaitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
try
{
- if (!System.IO.File.Exists(playlist))
+ if (!System.IO.File.Exists(playlistPath))
{
// If the playlist doesn't already exist, startup ffmpeg
try
{
job = await _transcodingJobHelper.StartFfMpeg(
state,
- playlist,
- GetCommandLineArguments(playlist, state),
+ playlistPath,
+ GetCommandLineArguments(playlistPath, state),
Request,
TranscodingJobType,
cancellationTokenSource)
@@ -328,7 +329,7 @@ namespace Jellyfin.Api.Controllers
minSegments = state.MinSegments;
if (minSegments > 0)
{
- await HlsHelpers.WaitForMinimumSegmentCount(playlist, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
+ await HlsHelpers.WaitForMinimumSegmentCount(playlistPath, minSegments, _logger, cancellationTokenSource.Token).ConfigureAwait(false);
}
}
}
@@ -338,14 +339,14 @@ namespace Jellyfin.Api.Controllers
}
}
- job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlist, TranscodingJobType);
+ job ??= _transcodingJobHelper.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
if (job != null)
{
_transcodingJobHelper.OnTranscodeEndRequest(job);
}
- var playlistText = HlsHelpers.GetLivePlaylistText(playlist, state.SegmentLength);
+ var playlistText = HlsHelpers.GetLivePlaylistText(playlistPath, state);
return Content(playlistText, MimeTypes.GetMimeType("playlist.m3u8"));
}
@@ -359,17 +360,46 @@ namespace Jellyfin.Api.Controllers
private string GetCommandLineArguments(string outputPath, StreamState state)
{
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
- var threads = _encodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
+ var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec); // GetNumberOfThreads is static.
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions);
- var format = !string.IsNullOrWhiteSpace(state.Request.SegmentContainer) ? "." + state.Request.SegmentContainer : ".ts";
+ var mapArgs = state.IsOutputVideo ? _encodingHelper.GetMapArgs(state) : string.Empty;
+
var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
- var outputTsArg = Path.Combine(directory, Path.GetFileNameWithoutExtension(outputPath)) + "%d" + format;
+ var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+ var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+ var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+ var outputTsArg = outputPrefix + "%d" + outputExtension;
- var segmentFormat = format.TrimStart('.');
+ var segmentFormat = outputExtension.TrimStart('.');
if (string.Equals(segmentFormat, "ts", StringComparison.OrdinalIgnoreCase))
{
segmentFormat = "mpegts";
}
+ else if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
+ {
+ var outputFmp4HeaderArg = string.Empty;
+ var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ if (isWindows)
+ {
+ // on Windows, the path of fmp4 header file needs to be configured
+ outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputPrefix + "-1" + outputExtension + "\"";
+ }
+ else
+ {
+ // on Linux/Unix, ffmpeg generate fmp4 header file to m3u8 output folder
+ outputFmp4HeaderArg = " -hls_fmp4_init_filename \"" + outputFileNameWithoutExtension + "-1" + outputExtension + "\"";
+ }
+
+ segmentFormat = "fmp4" + outputFmp4HeaderArg;
+ }
+ else
+ {
+ _logger.LogError("Invalid HLS segment container: {SegmentFormat}", segmentFormat);
+ }
+
+ var maxMuxingQueueSize = _encodingOptions.MaxMuxingQueueSize > 128
+ ? _encodingOptions.MaxMuxingQueueSize.ToString(CultureInfo.InvariantCulture)
+ : "128";
var baseUrlParam = string.Format(
CultureInfo.InvariantCulture,
@@ -378,20 +408,19 @@ namespace Jellyfin.Api.Controllers
return string.Format(
CultureInfo.InvariantCulture,
- "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -f segment -max_delay 5000000 -avoid_negative_ts disabled -start_at_zero -segment_time {6} {7} -individual_header_trailer 0 -segment_format {8} -segment_list_entry_prefix {9} -segment_list_type m3u8 -segment_start_number 0 -segment_list \"{10}\" -y \"{11}\"",
+ "{0} {1} -map_metadata -1 -map_chapters -1 -threads {2} {3} {4} {5} -copyts -avoid_negative_ts disabled -max_muxing_queue_size {6} -f hls -max_delay 5000000 -hls_time {7} -hls_segment_type {8} -start_number 0 -hls_base_url {9} -hls_playlist_type event -hls_segment_filename \"{10}\" -y \"{11}\"",
inputModifier,
_encodingHelper.GetInputArgument(state, _encodingOptions),
threads,
- _encodingHelper.GetMapArgs(state),
+ mapArgs,
GetVideoArguments(state),
GetAudioArguments(state),
+ maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
- string.Empty,
segmentFormat,
baseUrlParam,
- outputPath,
- outputTsArg)
- .Trim();
+ outputTsArg,
+ outputPath).Trim();
}
/// <summary>
@@ -401,14 +430,53 @@ namespace Jellyfin.Api.Controllers
/// <returns>The command line arguments for audio transcoding.</returns>
private string GetAudioArguments(StreamState state)
{
- var codec = _encodingHelper.GetAudioEncoder(state);
+ if (state.AudioStream == null)
+ {
+ return string.Empty;
+ }
- if (EncodingHelper.IsCopyCodec(codec))
+ var audioCodec = _encodingHelper.GetAudioEncoder(state);
+
+ if (!state.IsOutputVideo)
+ {
+ if (EncodingHelper.IsCopyCodec(audioCodec))
+ {
+ var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+
+ return "-acodec copy -strict -2" + bitStreamArgs;
+ }
+
+ var audioTranscodeParams = string.Empty;
+
+ audioTranscodeParams += "-acodec " + audioCodec;
+
+ if (state.OutputAudioBitrate.HasValue)
+ {
+ audioTranscodeParams += " -ab " + state.OutputAudioBitrate.Value.ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (state.OutputAudioChannels.HasValue)
+ {
+ audioTranscodeParams += " -ac " + state.OutputAudioChannels.Value.ToString(CultureInfo.InvariantCulture);
+ }
+
+ if (state.OutputAudioSampleRate.HasValue)
+ {
+ audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
+ }
+
+ audioTranscodeParams += " -vn";
+ return audioTranscodeParams;
+ }
+
+ if (EncodingHelper.IsCopyCodec(audioCodec))
{
- return "-codec:a:0 copy";
+ var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container);
+
+ return "-acodec copy -strict -2" + bitStreamArgs;
}
- var args = "-codec:a:0 " + codec;
+ var args = "-codec:a:0 " + audioCodec;
var channels = state.OutputAudioChannels;
@@ -429,7 +497,7 @@ namespace Jellyfin.Api.Controllers
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture);
}
- args += " " + _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
+ args += _encodingHelper.GetAudioFilterParam(state, _encodingOptions, true);
return args;
}
@@ -441,6 +509,11 @@ namespace Jellyfin.Api.Controllers
/// <returns>The command line arguments for video transcoding.</returns>
private string GetVideoArguments(StreamState state)
{
+ if (state.VideoStream == null)
+ {
+ return string.Empty;
+ }
+
if (!state.IsOutputVideo)
{
return string.Empty;
@@ -450,47 +523,65 @@ namespace Jellyfin.Api.Controllers
var args = "-codec:v:0 " + codec;
+ // Prefer hvc1 to hev1.
+ if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ args += " -tag:v:0 hvc1";
+ }
+
// if (state.EnableMpegtsM2TsMode)
// {
// args += " -mpegts_m2ts_mode 1";
// }
- // See if we can save come cpu cycles by avoiding encoding
- if (codec.Equals("copy", StringComparison.OrdinalIgnoreCase))
+ // See if we can save come cpu cycles by avoiding encoding.
+ if (EncodingHelper.IsCopyCodec(codec))
{
- // if h264_mp4toannexb is ever added, do not use it for live tv
- if (state.VideoStream != null &&
- !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
+ // If h264_mp4toannexb is ever added, do not use it for live tv.
+ if (state.VideoStream != null && !string.Equals(state.VideoStream.NalLengthSize, "0", StringComparison.OrdinalIgnoreCase))
{
- string bitStreamArgs = _encodingHelper.GetBitStreamArgs(state.VideoStream);
+ string bitStreamArgs = EncodingHelper.GetBitStreamArgs(state.VideoStream);
if (!string.IsNullOrEmpty(bitStreamArgs))
{
args += " " + bitStreamArgs;
}
}
+
+ args += " -start_at_zero";
}
else
{
- var keyFrameArg = string.Format(
- CultureInfo.InvariantCulture,
- " -force_key_frames \"expr:gte(t,n_forced*{0})\"",
- state.SegmentLength.ToString(CultureInfo.InvariantCulture));
+ args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset);
- var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+ // Set the key frame params for video encoding to match the hls segment time.
+ args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, true, null);
- args += " " + _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, DefaultEncoderPreset) + keyFrameArg;
-
- // Add resolution params, if specified
- if (!hasGraphicalSubs)
+ // Currenly b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
+ if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase))
{
- args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
+ args += " -bf 0";
}
- // This is for internal graphical subs
+ var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
+
if (hasGraphicalSubs)
{
+ // Graphical subs overlay and resolution params.
args += _encodingHelper.GetGraphicalSubtitleParam(state, _encodingOptions, codec);
}
+ else
+ {
+ // Resolution params.
+ args += _encodingHelper.GetOutputSizeParam(state, _encodingOptions, codec);
+ }
+
+ if (state.SubtitleStream == null || !state.SubtitleStream.IsExternal || state.SubtitleStream.IsTextSubtitleStream)
+ {
+ args += " -start_at_zero";
+ }
}
args += " -flags -global_header";
diff --git a/Jellyfin.Api/Controllers/VideosController.cs b/Jellyfin.Api/Controllers/VideosController.cs
index 4de7aac71..44dc63952 100644
--- a/Jellyfin.Api/Controllers/VideosController.cs
+++ b/Jellyfin.Api/Controllers/VideosController.cs
@@ -10,6 +10,7 @@ using Jellyfin.Api.Attributes;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
+using Jellyfin.Api.ModelBinders;
using Jellyfin.Api.Models.StreamingDtos;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
@@ -195,7 +196,7 @@ namespace Jellyfin.Api.Controllers
/// <summary>
/// Merges videos into a single record.
/// </summary>
- /// <param name="itemIds">Item id list. This allows multiple, comma delimited.</param>
+ /// <param name="ids">Item id list. This allows multiple, comma delimited.</param>
/// <response code="204">Videos merged.</response>
/// <response code="400">Supply at least 2 video ids.</response>
/// <returns>A <see cref="NoContentResult"/> indicating success, or a <see cref="BadRequestResult"/> if less than two ids were supplied.</returns>
@@ -203,9 +204,9 @@ namespace Jellyfin.Api.Controllers
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
- public async Task<ActionResult> MergeVersions([FromQuery, Required] string itemIds)
+ public async Task<ActionResult> MergeVersions([FromQuery, Required, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] Guid[] ids)
{
- var items = RequestHelpers.Split(itemIds, ',', true)
+ var items = ids
.Select(i => _libraryManager.GetItemById(i))
.OfType<Video>()
.OrderBy(i => i.Id)
@@ -283,7 +284,7 @@ namespace Jellyfin.Api.Controllers
/// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
/// <param name="playSessionId">The play session id.</param>
/// <param name="segmentContainer">The segment container.</param>
- /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="segmentLength">The segment length.</param>
/// <param name="minSegments">The minimum number of segments.</param>
/// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
/// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
@@ -312,29 +313,27 @@ namespace Jellyfin.Api.Controllers
/// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
/// <param name="requireAvc">Optional. Whether to require avc.</param>
/// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
- /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamorphic stream.</param>
/// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
/// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
/// <param name="liveStreamId">The live stream id.</param>
/// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
/// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
/// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
- /// <param name="transcodingReasons">Optional. The transcoding reason.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
/// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
/// <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>
/// <response code="200">Video stream returned.</response>
/// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
- [HttpGet("{itemId}/{stream=stream}.{container?}", Name = "GetVideoStreamWithExt")]
[HttpGet("{itemId}/stream")]
- [HttpHead("{itemId}/{stream=stream}.{container?}", Name = "HeadVideoStreamWithExt")]
[HttpHead("{itemId}/stream", Name = "HeadVideoStream")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesVideoFile]
public async Task<ActionResult> GetVideoStream(
[FromRoute, Required] Guid itemId,
- [FromRoute] string? container,
+ [FromQuery] string? container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
@@ -377,7 +376,7 @@ namespace Jellyfin.Api.Controllers
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] string? videoCodec,
[FromQuery] string? subtitleCodec,
- [FromQuery] string? transcodingReasons,
+ [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
[FromQuery] EncodingContext context,
@@ -431,7 +430,7 @@ namespace Jellyfin.Api.Controllers
EnableMpegtsM2TsMode = enableMpegtsM2TsMode ?? true,
VideoCodec = videoCodec,
SubtitleCodec = subtitleCodec,
- TranscodeReasons = transcodingReasons,
+ TranscodeReasons = transcodeReasons,
AudioStreamIndex = audioStreamIndex,
VideoStreamIndex = videoStreamIndex,
Context = context,
@@ -529,5 +528,166 @@ namespace Jellyfin.Api.Controllers
_transcodingJobType,
cancellationTokenSource).ConfigureAwait(false);
}
+
+ /// <summary>
+ /// Gets a video stream.
+ /// </summary>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="container">The video container. Possible values are: ts, webm, asf, wmv, ogv, mp4, m4v, mkv, mpeg, mpg, avi, 3gp, wmv, wtv, m2ts, mov, iso, flv. </param>
+ /// <param name="static">Optional. If true, the original file will be streamed statically without any encoding. Use either no url extension or the original file extension. true/false.</param>
+ /// <param name="params">The streaming parameters.</param>
+ /// <param name="tag">The tag.</param>
+ /// <param name="deviceProfileId">Optional. The dlna device profile id to utilize.</param>
+ /// <param name="playSessionId">The play session id.</param>
+ /// <param name="segmentContainer">The segment container.</param>
+ /// <param name="segmentLength">The segment lenght.</param>
+ /// <param name="minSegments">The minimum number of segments.</param>
+ /// <param name="mediaSourceId">The media version id, if playing an alternate version.</param>
+ /// <param name="deviceId">The device id of the client requesting. Used to stop encoding processes when needed.</param>
+ /// <param name="audioCodec">Optional. Specify a audio codec to encode to, e.g. mp3. If omitted the server will auto-select using the url's extension. Options: aac, mp3, vorbis, wma.</param>
+ /// <param name="enableAutoStreamCopy">Whether or not to allow automatic stream copy if requested values match the original source. Defaults to true.</param>
+ /// <param name="allowVideoStreamCopy">Whether or not to allow copying of the video stream url.</param>
+ /// <param name="allowAudioStreamCopy">Whether or not to allow copying of the audio stream url.</param>
+ /// <param name="breakOnNonKeyFrames">Optional. Whether to break on non key frames.</param>
+ /// <param name="audioSampleRate">Optional. Specify a specific audio sample rate, e.g. 44100.</param>
+ /// <param name="maxAudioBitDepth">Optional. The maximum audio bit depth.</param>
+ /// <param name="audioBitRate">Optional. Specify an audio bitrate to encode to, e.g. 128000. If omitted this will be left to encoder defaults.</param>
+ /// <param name="audioChannels">Optional. Specify a specific number of audio channels to encode to, e.g. 2.</param>
+ /// <param name="maxAudioChannels">Optional. Specify a maximum number of audio channels to encode to, e.g. 2.</param>
+ /// <param name="profile">Optional. Specify a specific an encoder profile (varies by encoder), e.g. main, baseline, high.</param>
+ /// <param name="level">Optional. Specify a level for the encoder profile (varies by encoder), e.g. 3, 3.1.</param>
+ /// <param name="framerate">Optional. A specific video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+ /// <param name="maxFramerate">Optional. A specific maximum video framerate to encode to, e.g. 23.976. Generally this should be omitted unless the device has specific requirements.</param>
+ /// <param name="copyTimestamps">Whether or not to copy timestamps when transcoding with an offset. Defaults to false.</param>
+ /// <param name="startTimeTicks">Optional. Specify a starting offset, in ticks. 1 tick = 10000 ms.</param>
+ /// <param name="width">Optional. The fixed horizontal resolution of the encoded video.</param>
+ /// <param name="height">Optional. The fixed vertical resolution of the encoded video.</param>
+ /// <param name="videoBitRate">Optional. Specify a video bitrate to encode to, e.g. 500000. If omitted this will be left to encoder defaults.</param>
+ /// <param name="subtitleStreamIndex">Optional. The index of the subtitle stream to use. If omitted no subtitles will be used.</param>
+ /// <param name="subtitleMethod">Optional. Specify the subtitle delivery method.</param>
+ /// <param name="maxRefFrames">Optional.</param>
+ /// <param name="maxVideoBitDepth">Optional. The maximum video bit depth.</param>
+ /// <param name="requireAvc">Optional. Whether to require avc.</param>
+ /// <param name="deInterlace">Optional. Whether to deinterlace the video.</param>
+ /// <param name="requireNonAnamorphic">Optional. Whether to require a non anamporphic stream.</param>
+ /// <param name="transcodingMaxAudioChannels">Optional. The maximum number of audio channels to transcode.</param>
+ /// <param name="cpuCoreLimit">Optional. The limit of how many cpu cores to use.</param>
+ /// <param name="liveStreamId">The live stream id.</param>
+ /// <param name="enableMpegtsM2TsMode">Optional. Whether to enable the MpegtsM2Ts mode.</param>
+ /// <param name="videoCodec">Optional. Specify a video codec to encode to, e.g. h264. If omitted the server will auto-select using the url's extension. Options: h265, h264, mpeg4, theora, vpx, wmv.</param>
+ /// <param name="subtitleCodec">Optional. Specify a subtitle codec to encode to.</param>
+ /// <param name="transcodeReasons">Optional. The transcoding reason.</param>
+ /// <param name="audioStreamIndex">Optional. The index of the audio stream to use. If omitted the first audio stream will be used.</param>
+ /// <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>
+ /// <response code="200">Video stream returned.</response>
+ /// <returns>A <see cref="FileResult"/> containing the audio file.</returns>
+ [HttpGet("{itemId}/{stream=stream}.{container}")]
+ [HttpHead("{itemId}/{stream=stream}.{container}", Name = "HeadVideoStreamByContainer")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesVideoFile]
+ public Task<ActionResult> GetVideoStreamByContainer(
+ [FromRoute, Required] Guid itemId,
+ [FromRoute, Required] string container,
+ [FromQuery] bool? @static,
+ [FromQuery] string? @params,
+ [FromQuery] string? tag,
+ [FromQuery] string? deviceProfileId,
+ [FromQuery] string? playSessionId,
+ [FromQuery] string? segmentContainer,
+ [FromQuery] int? segmentLength,
+ [FromQuery] int? minSegments,
+ [FromQuery] string? mediaSourceId,
+ [FromQuery] string? deviceId,
+ [FromQuery] string? audioCodec,
+ [FromQuery] bool? enableAutoStreamCopy,
+ [FromQuery] bool? allowVideoStreamCopy,
+ [FromQuery] bool? allowAudioStreamCopy,
+ [FromQuery] bool? breakOnNonKeyFrames,
+ [FromQuery] int? audioSampleRate,
+ [FromQuery] int? maxAudioBitDepth,
+ [FromQuery] int? audioBitRate,
+ [FromQuery] int? audioChannels,
+ [FromQuery] int? maxAudioChannels,
+ [FromQuery] string? profile,
+ [FromQuery] string? level,
+ [FromQuery] float? framerate,
+ [FromQuery] float? maxFramerate,
+ [FromQuery] bool? copyTimestamps,
+ [FromQuery] long? startTimeTicks,
+ [FromQuery] int? width,
+ [FromQuery] int? height,
+ [FromQuery] int? videoBitRate,
+ [FromQuery] int? subtitleStreamIndex,
+ [FromQuery] SubtitleDeliveryMethod subtitleMethod,
+ [FromQuery] int? maxRefFrames,
+ [FromQuery] int? maxVideoBitDepth,
+ [FromQuery] bool? requireAvc,
+ [FromQuery] bool? deInterlace,
+ [FromQuery] bool? requireNonAnamorphic,
+ [FromQuery] int? transcodingMaxAudioChannels,
+ [FromQuery] int? cpuCoreLimit,
+ [FromQuery] string? liveStreamId,
+ [FromQuery] bool? enableMpegtsM2TsMode,
+ [FromQuery] string? videoCodec,
+ [FromQuery] string? subtitleCodec,
+ [FromQuery] string? transcodeReasons,
+ [FromQuery] int? audioStreamIndex,
+ [FromQuery] int? videoStreamIndex,
+ [FromQuery] EncodingContext context,
+ [FromQuery] Dictionary<string, string> streamOptions)
+ {
+ return GetVideoStream(
+ itemId,
+ container,
+ @static,
+ @params,
+ tag,
+ deviceProfileId,
+ playSessionId,
+ segmentContainer,
+ segmentLength,
+ minSegments,
+ mediaSourceId,
+ deviceId,
+ audioCodec,
+ enableAutoStreamCopy,
+ allowVideoStreamCopy,
+ allowAudioStreamCopy,
+ breakOnNonKeyFrames,
+ audioSampleRate,
+ maxAudioBitDepth,
+ audioBitRate,
+ audioChannels,
+ maxAudioChannels,
+ profile,
+ level,
+ framerate,
+ maxFramerate,
+ copyTimestamps,
+ startTimeTicks,
+ width,
+ height,
+ videoBitRate,
+ subtitleStreamIndex,
+ subtitleMethod,
+ maxRefFrames,
+ maxVideoBitDepth,
+ requireAvc,
+ deInterlace,
+ requireNonAnamorphic,
+ transcodingMaxAudioChannels,
+ cpuCoreLimit,
+ liveStreamId,
+ enableMpegtsM2TsMode,
+ videoCodec,
+ subtitleCodec,
+ transcodeReasons,
+ audioStreamIndex,
+ videoStreamIndex,
+ context,
+ streamOptions);
+ }
}
}
diff --git a/Jellyfin.Api/Controllers/YearsController.cs b/Jellyfin.Api/Controllers/YearsController.cs
index 1b38e399d..48c639b08 100644
--- a/Jellyfin.Api/Controllers/YearsController.cs
+++ b/Jellyfin.Api/Controllers/YearsController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@@ -71,11 +71,11 @@ namespace Jellyfin.Api.Controllers
[FromQuery] int? startIndex,
[FromQuery] int? limit,
[FromQuery] string? sortOrder,
- [FromQuery] string? parentId,
+ [FromQuery] Guid? parentId,
[FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] ItemFields[] fields,
- [FromQuery] string? excludeItemTypes,
- [FromQuery] string? includeItemTypes,
- [FromQuery] string? mediaTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] excludeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] includeItemTypes,
+ [FromQuery, ModelBinder(typeof(CommaDelimitedArrayModelBinder))] string[] mediaTypes,
[FromQuery] string? sortBy,
[FromQuery] bool? enableUserData,
[FromQuery] int? imageTypeLimit,
@@ -89,33 +89,24 @@ namespace Jellyfin.Api.Controllers
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
User? user = null;
- BaseItem parentItem;
+ BaseItem parentItem = _libraryManager.GetParentItem(parentId, userId);
if (userId.HasValue && !userId.Equals(Guid.Empty))
{
user = _userManager.GetUserById(userId.Value);
- parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.GetUserRootFolder() : _libraryManager.GetItemById(parentId);
- }
- else
- {
- parentItem = string.IsNullOrEmpty(parentId) ? _libraryManager.RootFolder : _libraryManager.GetItemById(parentId);
}
IList<BaseItem> items;
- var excludeItemTypesArr = RequestHelpers.Split(excludeItemTypes, ',', true);
- var includeItemTypesArr = RequestHelpers.Split(includeItemTypes, ',', true);
- var mediaTypesArr = RequestHelpers.Split(mediaTypes, ',', true);
-
var query = new InternalItemsQuery(user)
{
- ExcludeItemTypes = excludeItemTypesArr,
- IncludeItemTypes = includeItemTypesArr,
- MediaTypes = mediaTypesArr,
+ ExcludeItemTypes = excludeItemTypes,
+ IncludeItemTypes = includeItemTypes,
+ MediaTypes = mediaTypes,
DtoOptions = dtoOptions
};
- bool Filter(BaseItem i) => FilterItem(i, excludeItemTypesArr, includeItemTypesArr, mediaTypesArr);
+ bool Filter(BaseItem i) => FilterItem(i, excludeItemTypes, includeItemTypes, mediaTypes);
if (parentItem.IsFolder)
{
diff --git a/Jellyfin.Api/Helpers/ClassMigrationHelper.cs b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs
new file mode 100644
index 000000000..a911a3324
--- /dev/null
+++ b/Jellyfin.Api/Helpers/ClassMigrationHelper.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Reflection;
+
+namespace Jellyfin.Api.Helpers
+{
+ /// <summary>
+ /// A static class for copying matching properties from one object to another.
+ /// TODO: remove at the point when a fixed migration path has been decided upon.
+ /// </summary>
+ public static class ClassMigrationHelper
+ {
+ /// <summary>
+ /// Extension for 'Object' that copies the properties to a destination object.
+ /// </summary>
+ /// <param name="source">The source.</param>
+ /// <param name="destination">The destination.</param>
+ public static void CopyProperties(this object source, object destination)
+ {
+ // If any this null throw an exception.
+ if (source == null || destination == null)
+ {
+ throw new Exception("Source or/and Destination Objects are null");
+ }
+
+ // Getting the Types of the objects.
+ Type typeDest = destination.GetType();
+ Type typeSrc = source.GetType();
+
+ // Iterate the Properties of the source instance and populate them from their destination counterparts.
+ PropertyInfo[] srcProps = typeSrc.GetProperties();
+ foreach (PropertyInfo srcProp in srcProps)
+ {
+ if (!srcProp.CanRead)
+ {
+ continue;
+ }
+
+ var targetProperty = typeDest.GetProperty(srcProp.Name);
+ if (targetProperty == null)
+ {
+ continue;
+ }
+
+ if (!targetProperty.CanWrite)
+ {
+ continue;
+ }
+
+ var obj = targetProperty.GetSetMethod(true);
+ if (obj != null && obj.IsPrivate)
+ {
+ continue;
+ }
+
+ var target = targetProperty.GetSetMethod();
+ if (target != null && (target.Attributes & MethodAttributes.Static) != 0)
+ {
+ continue;
+ }
+
+ if (!targetProperty.PropertyType.IsAssignableFrom(srcProp.PropertyType))
+ {
+ continue;
+ }
+
+ // Passed all tests, lets set the value.
+ targetProperty.SetValue(destination, srcProp.GetValue(source, null), null);
+ }
+ }
+ }
+}
diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
index e7fac50c6..a4da54cfd 100644
--- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
+++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs
@@ -207,7 +207,61 @@ namespace Jellyfin.Api.Helpers
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
}
- AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
+ var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
+
+ if (state.VideoStream != null && state.VideoRequest != null)
+ {
+ // Provide SDR HEVC entrance for backward compatibility.
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
+ && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
+ if (requestedVideoProfiles != null && requestedVideoProfiles.Length > 0)
+ {
+ // Force HEVC Main Profile and disable video stream copy.
+ state.OutputVideoCodec = "hevc";
+ var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(",", requestedVideoProfiles), "main");
+ sdrVideoUrl += "&AllowVideoStreamCopy=false";
+
+ EncodingHelper encodingHelper = new EncodingHelper(_mediaEncoder, _fileSystem, _subtitleEncoder, _configuration);
+ var sdrOutputVideoBitrate = encodingHelper.GetVideoBitrateParamValue(state.VideoRequest, state.VideoStream, state.OutputVideoCodec) ?? 0;
+ var sdrOutputAudioBitrate = encodingHelper.GetAudioBitrateParam(state.VideoRequest, state.AudioStream) ?? 0;
+ var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate;
+
+ AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup);
+
+ // Restore the video codec
+ state.OutputVideoCodec = "copy";
+ }
+ }
+
+ // Provide Level 5.0 entrance for backward compatibility.
+ // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
+ // but in fact it is capable of playing videos up to Level 6.1.
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && state.VideoStream.Level.HasValue
+ && state.VideoStream.Level > 150
+ && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
+ && string.Equals(state.VideoStream.VideoRange, "SDR", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ var playlistCodecsField = new StringBuilder();
+ AppendPlaylistCodecsField(playlistCodecsField, state);
+
+ // Force the video level to 5.0.
+ var originalLevel = state.VideoStream.Level;
+ state.VideoStream.Level = 150;
+ var newPlaylistCodecsField = new StringBuilder();
+ AppendPlaylistCodecsField(newPlaylistCodecsField, state);
+
+ // Restore the video level.
+ state.VideoStream.Level = originalLevel;
+ var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField);
+ builder.Append(newPlaylist);
+ }
+ }
if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp()))
{
@@ -217,40 +271,77 @@ namespace Jellyfin.Api.Helpers
var variation = GetBitrateVariation(totalBitrate);
var newBitrate = totalBitrate - variation;
- var variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+ var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
variation *= 2;
newBitrate = totalBitrate - variation;
- variantUrl = ReplaceBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
+ variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
}
return new FileContentResult(Encoding.UTF8.GetBytes(builder.ToString()), MimeTypes.GetMimeType("playlist.m3u8"));
}
- private void AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
+ private StringBuilder AppendPlaylist(StringBuilder builder, StreamState state, string url, int bitrate, string? subtitleGroup)
{
- builder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
+ var playlistBuilder = new StringBuilder();
+ playlistBuilder.Append("#EXT-X-STREAM-INF:BANDWIDTH=")
.Append(bitrate.ToString(CultureInfo.InvariantCulture))
.Append(",AVERAGE-BANDWIDTH=")
.Append(bitrate.ToString(CultureInfo.InvariantCulture));
- AppendPlaylistCodecsField(builder, state);
+ AppendPlaylistVideoRangeField(playlistBuilder, state);
+
+ AppendPlaylistCodecsField(playlistBuilder, state);
- AppendPlaylistResolutionField(builder, state);
+ AppendPlaylistResolutionField(playlistBuilder, state);
- AppendPlaylistFramerateField(builder, state);
+ AppendPlaylistFramerateField(playlistBuilder, state);
if (!string.IsNullOrWhiteSpace(subtitleGroup))
{
- builder.Append(",SUBTITLES=\"")
+ playlistBuilder.Append(",SUBTITLES=\"")
.Append(subtitleGroup)
.Append('"');
}
- builder.Append(Environment.NewLine);
- builder.AppendLine(url);
+ playlistBuilder.Append(Environment.NewLine);
+ playlistBuilder.AppendLine(url);
+ builder.Append(playlistBuilder);
+
+ return playlistBuilder;
+ }
+
+ /// <summary>
+ /// Appends a VIDEO-RANGE field containing the range of the output video stream.
+ /// </summary>
+ /// <seealso cref="AppendPlaylist(StringBuilder, StreamState, string, int, string)"/>
+ /// <param name="builder">StringBuilder to append the field to.</param>
+ /// <param name="state">StreamState of the current stream.</param>
+ private void AppendPlaylistVideoRangeField(StringBuilder builder, StreamState state)
+ {
+ if (state.VideoStream != null && !string.IsNullOrEmpty(state.VideoStream.VideoRange))
+ {
+ var videoRange = state.VideoStream.VideoRange;
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+ {
+ if (string.Equals(videoRange, "SDR", StringComparison.OrdinalIgnoreCase))
+ {
+ builder.Append(",VIDEO-RANGE=SDR");
+ }
+
+ if (string.Equals(videoRange, "HDR", StringComparison.OrdinalIgnoreCase))
+ {
+ builder.Append(",VIDEO-RANGE=PQ");
+ }
+ }
+ else
+ {
+ // Currently we only encode to SDR.
+ builder.Append(",VIDEO-RANGE=SDR");
+ }
+ }
}
/// <summary>
@@ -419,15 +510,27 @@ namespace Jellyfin.Api.Helpers
/// <returns>H.26X level of the output video stream.</returns>
private int? GetOutputVideoCodecLevel(StreamState state)
{
- string? levelString;
+ string levelString = string.Empty;
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && state.VideoStream != null
&& state.VideoStream.Level.HasValue)
{
- levelString = state.VideoStream?.Level.ToString();
+ levelString = state.VideoStream.Level.ToString() ?? string.Empty;
}
else
{
- levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec);
+ if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ levelString = state.GetRequestedLevel(state.ActualOutputVideoCodec) ?? "41";
+ levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
+ }
+
+ if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ levelString = state.GetRequestedLevel("h265") ?? state.GetRequestedLevel("hevc") ?? "120";
+ levelString = EncodingHelper.NormalizeTranscodingLevel(state, levelString);
+ }
}
if (int.TryParse(levelString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLevel))
@@ -439,6 +542,38 @@ namespace Jellyfin.Api.Helpers
}
/// <summary>
+ /// Get the H.26X profile of the output video stream.
+ /// </summary>
+ /// <param name="state">StreamState of the current stream.</param>
+ /// <param name="codec">Video codec.</param>
+ /// <returns>H.26X profile of the output video stream.</returns>
+ private string GetOutputVideoCodecProfile(StreamState state, string codec)
+ {
+ string profileString = string.Empty;
+ if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
+ && !string.IsNullOrEmpty(state.VideoStream.Profile))
+ {
+ profileString = state.VideoStream.Profile;
+ }
+ else if (!string.IsNullOrEmpty(codec))
+ {
+ profileString = state.GetRequestedProfiles(codec).FirstOrDefault() ?? string.Empty;
+ if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ profileString = profileString ?? "high";
+ }
+
+ if (string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ profileString = profileString ?? "main";
+ }
+ }
+
+ return profileString;
+ }
+
+ /// <summary>
/// Gets a formatted string of the output audio codec, for use in the CODECS field.
/// </summary>
/// <seealso cref="AppendPlaylistCodecsField(StringBuilder, StreamState)"/>
@@ -468,6 +603,16 @@ namespace Jellyfin.Api.Helpers
return HlsCodecStringHelpers.GetEAC3String();
}
+ if (string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase))
+ {
+ return HlsCodecStringHelpers.GetFLACString();
+ }
+
+ if (string.Equals(state.ActualOutputAudioCodec, "alac", StringComparison.OrdinalIgnoreCase))
+ {
+ return HlsCodecStringHelpers.GetALACString();
+ }
+
return string.Empty;
}
@@ -492,15 +637,14 @@ namespace Jellyfin.Api.Helpers
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase))
{
- string? profile = state.GetRequestedProfiles("h264").FirstOrDefault();
+ string profile = GetOutputVideoCodecProfile(state, "h264");
return HlsCodecStringHelpers.GetH264String(profile, level);
}
if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase))
{
- string? profile = state.GetRequestedProfiles("h265").FirstOrDefault();
-
+ string profile = GetOutputVideoCodecProfile(state, "hevc");
return HlsCodecStringHelpers.GetH265String(profile, level);
}
@@ -544,12 +688,30 @@ namespace Jellyfin.Api.Helpers
return variation;
}
- private string ReplaceBitrate(string url, int oldValue, int newValue)
+ private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
{
return url.Replace(
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
StringComparison.OrdinalIgnoreCase);
}
+
+ private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
+ {
+ string profileStr = codec + "-profile=";
+ return url.Replace(
+ profileStr + oldValue,
+ profileStr + newValue,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
+ {
+ var oldPlaylist = playlist.ToString();
+ return oldPlaylist.Replace(
+ oldValue.ToString(),
+ newValue.ToString(),
+ StringComparison.OrdinalIgnoreCase);
+ }
}
}
diff --git a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
index 20c94cdda..cfa2c1229 100644
--- a/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
+++ b/Jellyfin.Api/Helpers/FileStreamResponseHelpers.cs
@@ -24,12 +24,14 @@ namespace Jellyfin.Api.Helpers
/// <param name="isHeadRequest">Whether the current request is a HTTP HEAD request so only the headers get returned.</param>
/// <param name="httpClient">The <see cref="HttpClient"/> making the remote request.</param>
/// <param name="httpContext">The current http context.</param>
+ /// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A <see cref="Task{ActionResult}"/> containing the API response.</returns>
public static async Task<ActionResult> GetStaticRemoteStreamResult(
StreamState state,
bool isHeadRequest,
HttpClient httpClient,
- HttpContext httpContext)
+ HttpContext httpContext,
+ CancellationToken cancellationToken = default)
{
if (state.RemoteHttpHeaders.TryGetValue(HeaderNames.UserAgent, out var useragent))
{
@@ -47,7 +49,7 @@ namespace Jellyfin.Api.Helpers
return new FileContentResult(Array.Empty<byte>(), contentType);
}
- return new FileStreamResult(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), contentType);
+ return new FileStreamResult(await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), contentType);
}
/// <summary>
diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
index 1bd3d67ff..a5369c441 100644
--- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
+++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs
@@ -10,12 +10,37 @@ namespace Jellyfin.Api.Helpers
public static class HlsCodecStringHelpers
{
/// <summary>
+ /// Codec name for MP3.
+ /// </summary>
+ public const string MP3 = "mp4a.40.34";
+
+ /// <summary>
+ /// Codec name for AC-3.
+ /// </summary>
+ public const string AC3 = "mp4a.a5";
+
+ /// <summary>
+ /// Codec name for E-AC-3.
+ /// </summary>
+ public const string EAC3 = "mp4a.a6";
+
+ /// <summary>
+ /// Codec name for FLAC.
+ /// </summary>
+ public const string FLAC = "fLaC";
+
+ /// <summary>
+ /// Codec name for ALAC.
+ /// </summary>
+ public const string ALAC = "alac";
+
+ /// <summary>
/// Gets a MP3 codec string.
/// </summary>
/// <returns>MP3 codec string.</returns>
public static string GetMP3String()
{
- return "mp4a.40.34";
+ return MP3;
}
/// <summary>
@@ -41,6 +66,42 @@ namespace Jellyfin.Api.Helpers
}
/// <summary>
+ /// Gets an AC-3 codec string.
+ /// </summary>
+ /// <returns>AC-3 codec string.</returns>
+ public static string GetAC3String()
+ {
+ return AC3;
+ }
+
+ /// <summary>
+ /// Gets an E-AC-3 codec string.
+ /// </summary>
+ /// <returns>E-AC-3 codec string.</returns>
+ public static string GetEAC3String()
+ {
+ return EAC3;
+ }
+
+ /// <summary>
+ /// Gets an FLAC codec string.
+ /// </summary>
+ /// <returns>FLAC codec string.</returns>
+ public static string GetFLACString()
+ {
+ return FLAC;
+ }
+
+ /// <summary>
+ /// Gets an ALAC codec string.
+ /// </summary>
+ /// <returns>ALAC codec string.</returns>
+ public static string GetALACString()
+ {
+ return ALAC;
+ }
+
+ /// <summary>
/// Gets a H.264 codec string.
/// </summary>
/// <param name="profile">H.264 profile.</param>
@@ -85,41 +146,24 @@ namespace Jellyfin.Api.Helpers
// The h265 syntax is a bit of a mystery at the time this comment was written.
// This is what I've found through various sources:
// FORMAT: [codecTag].[profile].[constraint?].L[level * 30].[UNKNOWN]
- StringBuilder result = new StringBuilder("hev1", 16);
+ StringBuilder result = new StringBuilder("hvc1", 16);
- if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(profile, "main10", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(profile, "main 10", StringComparison.OrdinalIgnoreCase))
{
- result.Append(".2.6");
+ result.Append(".2.4");
}
else
{
// Default to main if profile is invalid
- result.Append(".1.6");
+ result.Append(".1.4");
}
result.Append(".L")
- .Append(level * 3)
+ .Append(level)
.Append(".B0");
return result.ToString();
}
-
- /// <summary>
- /// Gets an AC-3 codec string.
- /// </summary>
- /// <returns>AC-3 codec string.</returns>
- public static string GetAC3String()
- {
- return "mp4a.a5";
- }
-
- /// <summary>
- /// Gets an E-AC-3 codec string.
- /// </summary>
- /// <returns>E-AC-3 codec string.</returns>
- public static string GetEAC3String()
- {
- return "mp4a.a6";
- }
}
}
diff --git a/Jellyfin.Api/Helpers/HlsHelpers.cs b/Jellyfin.Api/Helpers/HlsHelpers.cs
index 45ce90566..18e23fb5c 100644
--- a/Jellyfin.Api/Helpers/HlsHelpers.cs
+++ b/Jellyfin.Api/Helpers/HlsHelpers.cs
@@ -1,8 +1,11 @@
using System;
using System.Globalization;
using System.IO;
+using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Api.Models.StreamingDtos;
+using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
@@ -75,24 +78,64 @@ namespace Jellyfin.Api.Helpers
}
/// <summary>
+ /// Gets the #EXT-X-MAP string.
+ /// </summary>
+ /// <param name="outputPath">The output path of the file.</param>
+ /// <param name="state">The <see cref="StreamState"/>.</param>
+ /// <param name="isOsDepends">Get a normal string or depends on OS.</param>
+ /// <returns>The string text of #EXT-X-MAP.</returns>
+ public static string GetFmp4InitFileName(string outputPath, StreamState state, bool isOsDepends)
+ {
+ var directory = Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath));
+ var outputFileNameWithoutExtension = Path.GetFileNameWithoutExtension(outputPath);
+ var outputPrefix = Path.Combine(directory, outputFileNameWithoutExtension);
+ var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
+
+ // on Linux/Unix
+ // #EXT-X-MAP:URI="prefix-1.mp4"
+ var fmp4InitFileName = outputFileNameWithoutExtension + "-1" + outputExtension;
+ if (!isOsDepends)
+ {
+ return fmp4InitFileName;
+ }
+
+ var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ if (isWindows)
+ {
+ // on Windows
+ // #EXT-X-MAP:URI="X:\transcodes\prefix-1.mp4"
+ fmp4InitFileName = outputPrefix + "-1" + outputExtension;
+ }
+
+ return fmp4InitFileName;
+ }
+
+ /// <summary>
/// Gets the hls playlist text.
/// </summary>
/// <param name="path">The path to the playlist file.</param>
- /// <param name="segmentLength">The segment length.</param>
+ /// <param name="state">The <see cref="StreamState"/>.</param>
/// <returns>The playlist text as a string.</returns>
- public static string GetLivePlaylistText(string path, int segmentLength)
+ public static string GetLivePlaylistText(string path, StreamState state)
{
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(stream);
var text = reader.ReadToEnd();
- text = text.Replace("#EXTM3U", "#EXTM3U\n#EXT-X-PLAYLIST-TYPE:EVENT", StringComparison.InvariantCulture);
-
- var newDuration = "#EXT-X-TARGETDURATION:" + segmentLength.ToString(CultureInfo.InvariantCulture);
+ var segmentFormat = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer).TrimStart('.');
+ if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase))
+ {
+ var fmp4InitFileName = GetFmp4InitFileName(path, state, true);
+ var baseUrlParam = string.Format(
+ CultureInfo.InvariantCulture,
+ "hls/{0}/",
+ Path.GetFileNameWithoutExtension(path));
+ var newFmp4InitFileName = baseUrlParam + GetFmp4InitFileName(path, state, false);
- text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength - 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
- // text = text.Replace("#EXT-X-TARGETDURATION:" + (segmentLength + 1).ToString(CultureInfo.InvariantCulture), newDuration, StringComparison.OrdinalIgnoreCase);
+ // Replace fMP4 init file URI.
+ text = text.Replace(fmp4InitFileName, newFmp4InitFileName, StringComparison.InvariantCulture);
+ }
return text;
}
diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs
index f06f038ab..efce11f8a 100644
--- a/Jellyfin.Api/Helpers/RequestHelpers.cs
+++ b/Jellyfin.Api/Helpers/RequestHelpers.cs
@@ -122,49 +122,6 @@ namespace Jellyfin.Api.Helpers
return session;
}
- /// <summary>
- /// Get Guid array from string.
- /// </summary>
- /// <param name="value">String value.</param>
- /// <returns>Guid array.</returns>
- internal static Guid[] GetGuids(string? value)
- {
- if (value == null)
- {
- return Array.Empty<Guid>();
- }
-
- return Split(value, ',', true)
- .Select(i => new Guid(i))
- .ToArray();
- }
-
- /// <summary>
- /// Gets the item fields.
- /// </summary>
- /// <param name="fields">The fields string.</param>
- /// <returns>IEnumerable{ItemFields}.</returns>
- internal static ItemFields[] GetItemFields(string? fields)
- {
- if (string.IsNullOrEmpty(fields))
- {
- return Array.Empty<ItemFields>();
- }
-
- return Split(fields, ',', true)
- .Select(v =>
- {
- if (Enum.TryParse(v, true, out ItemFields value))
- {
- return (ItemFields?)value;
- }
-
- return null;
- }).Where(i => i.HasValue)
- .Select(i => i!.Value)
- .ToArray();
- }
-
internal static QueryResult<BaseItemDto> CreateQueryResult(
QueryResult<(BaseItem, ItemCounts)> result,
DtoOptions dtoOptions,
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index 0566f4c4d..c6d844c4f 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -169,7 +169,9 @@ namespace Jellyfin.Api.Helpers
state.DirectStreamProvider = liveStreamInfo.Item2;
}
- encodingHelper.AttachMediaSourceInfo(state, mediaSource, url);
+ var encodingOptions = serverConfigurationManager.GetEncodingOptions();
+
+ encodingHelper.AttachMediaSourceInfo(state, encodingOptions, mediaSource, url);
string? containerInternal = Path.GetExtension(state.RequestedUrl);
@@ -187,7 +189,7 @@ namespace Jellyfin.Api.Helpers
state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.');
- state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, state.AudioStream);
+ state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream);
state.OutputAudioCodec = streamingRequest.AudioCodec;
@@ -200,20 +202,41 @@ namespace Jellyfin.Api.Helpers
encodingHelper.TryStreamCopy(state);
- if (state.OutputVideoBitrate.HasValue && !EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
+ if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && state.OutputVideoBitrate.HasValue)
{
- var resolution = ResolutionNormalizer.Normalize(
- state.VideoStream?.BitRate,
- state.VideoStream?.Width,
- state.VideoStream?.Height,
- state.OutputVideoBitrate.Value,
- state.VideoStream?.Codec,
- state.OutputVideoCodec,
- state.VideoRequest.MaxWidth,
- state.VideoRequest.MaxHeight);
-
- state.VideoRequest.MaxWidth = resolution.MaxWidth;
- state.VideoRequest.MaxHeight = resolution.MaxHeight;
+ var isVideoResolutionNotRequested = !state.VideoRequest.Width.HasValue
+ && !state.VideoRequest.Height.HasValue
+ && !state.VideoRequest.MaxWidth.HasValue
+ && !state.VideoRequest.MaxHeight.HasValue;
+
+ if (isVideoResolutionNotRequested
+ && state.VideoRequest.VideoBitRate.HasValue
+ && state.VideoStream.BitRate.HasValue
+ && state.VideoRequest.VideoBitRate.Value >= state.VideoStream.BitRate.Value)
+ {
+ // Don't downscale the resolution if the width/height/MaxWidth/MaxHeight is not requested,
+ // and the requested video bitrate is higher than source video bitrate.
+ if (state.VideoStream.Width.HasValue || state.VideoStream.Height.HasValue)
+ {
+ state.VideoRequest.MaxWidth = state.VideoStream?.Width;
+ state.VideoRequest.MaxHeight = state.VideoStream?.Height;
+ }
+ }
+ else
+ {
+ var resolution = ResolutionNormalizer.Normalize(
+ state.VideoStream?.BitRate,
+ state.VideoStream?.Width,
+ state.VideoStream?.Height,
+ state.OutputVideoBitrate.Value,
+ state.VideoStream?.Codec,
+ state.OutputVideoCodec,
+ state.VideoRequest.MaxWidth,
+ state.VideoRequest.MaxHeight);
+
+ state.VideoRequest.MaxWidth = resolution.MaxWidth;
+ state.VideoRequest.MaxHeight = resolution.MaxHeight;
+ }
}
}
diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs
index 168dc27a8..240d132b1 100644
--- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs
+++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
@@ -12,7 +12,6 @@ using Jellyfin.Api.Models.PlaybackDtos;
using Jellyfin.Api.Models.StreamingDtos;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
@@ -46,7 +45,6 @@ namespace Jellyfin.Api.Helpers
private readonly IAuthorizationContext _authorizationContext;
private readonly EncodingHelper _encodingHelper;
private readonly IFileSystem _fileSystem;
- private readonly IIsoManager _isoManager;
private readonly ILogger<TranscodingJobHelper> _logger;
private readonly IMediaEncoder _mediaEncoder;
private readonly IMediaSourceManager _mediaSourceManager;
@@ -64,7 +62,6 @@ namespace Jellyfin.Api.Helpers
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="authorizationContext">Instance of the <see cref="IAuthorizationContext"/> interface.</param>
- /// <param name="isoManager">Instance of the <see cref="IIsoManager"/> interface.</param>
/// <param name="subtitleEncoder">Instance of the <see cref="ISubtitleEncoder"/> interface.</param>
/// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param>
/// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
@@ -76,7 +73,6 @@ namespace Jellyfin.Api.Helpers
IServerConfigurationManager serverConfigurationManager,
ISessionManager sessionManager,
IAuthorizationContext authorizationContext,
- IIsoManager isoManager,
ISubtitleEncoder subtitleEncoder,
IConfiguration configuration,
ILoggerFactory loggerFactory)
@@ -88,7 +84,6 @@ namespace Jellyfin.Api.Helpers
_serverConfigurationManager = serverConfigurationManager;
_sessionManager = sessionManager;
_authorizationContext = authorizationContext;
- _isoManager = isoManager;
_loggerFactory = loggerFactory;
_encodingHelper = new EncodingHelper(mediaEncoder, fileSystem, subtitleEncoder, configuration);
@@ -145,7 +140,7 @@ namespace Jellyfin.Api.Helpers
lock (_activeTranscodingJobs)
{
// This is really only needed for HLS.
- // Progressive streams can stop on their own reliably
+ // Progressive streams can stop on their own reliably.
jobs = _activeTranscodingJobs.Where(j => string.Equals(playSessionId, j.PlaySessionId, StringComparison.OrdinalIgnoreCase)).ToList();
}
@@ -241,7 +236,7 @@ namespace Jellyfin.Api.Helpers
lock (_activeTranscodingJobs)
{
// This is really only needed for HLS.
- // Progressive streams can stop on their own reliably
+ // Progressive streams can stop on their own reliably.
jobs.AddRange(_activeTranscodingJobs.Where(killJob));
}
@@ -304,10 +299,10 @@ namespace Jellyfin.Api.Helpers
process!.StandardInput.WriteLine("q");
- // Need to wait because killing is asynchronous
+ // Need to wait because killing is asynchronous.
if (!process.WaitForExit(5000))
{
- _logger.LogInformation("Killing ffmpeg process for {Path}", job.Path);
+ _logger.LogInformation("Killing FFmpeg process for {Path}", job.Path);
process.Kill();
}
}
@@ -470,11 +465,11 @@ namespace Jellyfin.Api.Helpers
}
/// <summary>
- /// Starts the FFMPEG.
+ /// Starts FFmpeg.
/// </summary>
/// <param name="state">The state.</param>
/// <param name="outputPath">The output path.</param>
- /// <param name="commandLineArguments">The command line arguments for ffmpeg.</param>
+ /// <param name="commandLineArguments">The command line arguments for FFmpeg.</param>
/// <param name="request">The <see cref="HttpRequest"/>.</param>
/// <param name="transcodingJobType">The <see cref="TranscodingJobType"/>.</param>
/// <param name="cancellationTokenSource">The cancellation token source.</param>
@@ -501,13 +496,13 @@ namespace Jellyfin.Api.Helpers
{
this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
- throw new ArgumentException("User does not have access to video transcoding");
+ throw new ArgumentException("User does not have access to video transcoding.");
}
}
if (string.IsNullOrEmpty(_mediaEncoder.EncoderPath))
{
- throw new ArgumentException("FFMPEG path not set.");
+ throw new ArgumentException("FFmpeg path not set.");
}
var process = new Process
@@ -544,18 +539,20 @@ namespace Jellyfin.Api.Helpers
var commandLineLogMessage = process.StartInfo.FileName + " " + process.StartInfo.Arguments;
_logger.LogInformation(commandLineLogMessage);
- var logFilePrefix = "ffmpeg-transcode";
+ var logFilePrefix = "FFmpeg.Transcode-";
if (state.VideoRequest != null
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec))
{
logFilePrefix = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
- ? "ffmpeg-remux"
- : "ffmpeg-directstream";
+ ? "FFmpeg.Remux-"
+ : "FFmpeg.DirectStream-";
}
- var logFilePath = Path.Combine(_serverConfigurationManager.ApplicationPaths.LogDirectoryPath, logFilePrefix + "-" + Guid.NewGuid() + ".txt");
+ var logFilePath = Path.Combine(
+ _serverConfigurationManager.ApplicationPaths.LogDirectoryPath,
+ $"{logFilePrefix}{DateTime.Now:yyyy-MM-dd_HH-mm-ss}_{state.Request.MediaSourceId}_{Guid.NewGuid().ToString()[..8]}.log");
- // FFMpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory.
+ // FFmpeg writes debug/error info to stderr. This is useful when debugging so let's put it in the log directory.
Stream logStream = new FileStream(logFilePath, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
var commandLineLogMessageBytes = Encoding.UTF8.GetBytes(request.Path + Environment.NewLine + Environment.NewLine + JsonSerializer.Serialize(state.MediaSource) + Environment.NewLine + Environment.NewLine + commandLineLogMessage + Environment.NewLine + Environment.NewLine);
@@ -569,20 +566,20 @@ namespace Jellyfin.Api.Helpers
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error starting ffmpeg");
+ _logger.LogError(ex, "Error starting FFmpeg");
this.OnTranscodeFailedToStart(outputPath, transcodingJobType, state);
throw;
}
- _logger.LogDebug("Launched ffmpeg process");
+ _logger.LogDebug("Launched FFmpeg process");
state.TranscodingJob = transcodingJob;
- // Important - don't await the log task or we won't be able to kill ffmpeg when the user stops playback
+ // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback
_ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream);
- // Wait for the file to exist before proceeeding
+ // Wait for the file to exist before proceeding
var ffmpegTargetFile = state.WaitForPath ?? outputPath;
_logger.LogDebug("Waiting for the creation of {0}", ffmpegTargetFile);
while (!File.Exists(ffmpegTargetFile) && !transcodingJob.HasExited)
@@ -748,11 +745,11 @@ namespace Jellyfin.Api.Helpers
if (process.ExitCode == 0)
{
- _logger.LogInformation("FFMpeg exited with code 0");
+ _logger.LogInformation("FFmpeg exited with code 0");
}
else
{
- _logger.LogError("FFMpeg exited with code {0}", process.ExitCode);
+ _logger.LogError("FFmpeg exited with code {0}", process.ExitCode);
}
process.Dispose();
@@ -760,19 +757,15 @@ namespace Jellyfin.Api.Helpers
private async Task AcquireResources(StreamState state, CancellationTokenSource cancellationTokenSource)
{
- if (state.VideoType == VideoType.Iso && state.IsoType.HasValue && _isoManager.CanMount(state.MediaPath))
- {
- state.IsoMount = await _isoManager.Mount(state.MediaPath, cancellationTokenSource.Token).ConfigureAwait(false);
- }
-
if (state.MediaSource.RequiresOpening && string.IsNullOrWhiteSpace(state.Request.LiveStreamId))
{
var liveStreamResponse = await _mediaSourceManager.OpenLiveStream(
new LiveStreamRequest { OpenToken = state.MediaSource.OpenToken },
cancellationTokenSource.Token)
.ConfigureAwait(false);
+ var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
- _encodingHelper.AttachMediaSourceInfo(state, liveStreamResponse.MediaSource, state.RequestedUrl);
+ _encodingHelper.AttachMediaSourceInfo(state, encodingOptions, liveStreamResponse.MediaSource, state.RequestedUrl);
if (state.VideoRequest != null)
{
diff --git a/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs
new file mode 100644
index 000000000..e1cb725f3
--- /dev/null
+++ b/Jellyfin.Api/ModelBinders/LegacyDateTimeModelBinder.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Globalization;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+ /// <summary>
+ /// DateTime model binder.
+ /// </summary>
+ public class LegacyDateTimeModelBinder : IModelBinder
+ {
+ // Borrowed from the DateTimeModelBinderProvider
+ private const DateTimeStyles SupportedStyles = DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces;
+ private readonly DateTimeModelBinder _defaultModelBinder;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="LegacyDateTimeModelBinder"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public LegacyDateTimeModelBinder(ILoggerFactory loggerFactory)
+ {
+ _defaultModelBinder = new DateTimeModelBinder(SupportedStyles, loggerFactory);
+ }
+
+ /// <inheritdoc />
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+ if (valueProviderResult.Values.Count == 1)
+ {
+ var dateTimeString = valueProviderResult.FirstValue;
+ // Mark Played Item.
+ if (DateTime.TryParseExact(dateTimeString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
+ {
+ bindingContext.Result = ModelBindingResult.Success(dateTime);
+ }
+ else
+ {
+ return _defaultModelBinder.BindModelAsync(bindingContext);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs
new file mode 100644
index 000000000..5d296227e
--- /dev/null
+++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinder.cs
@@ -0,0 +1,47 @@
+using System;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+ /// <summary>
+ /// Nullable enum model binder.
+ /// </summary>
+ public class NullableEnumModelBinder : IModelBinder
+ {
+ private readonly ILogger<NullableEnumModelBinder> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NullableEnumModelBinder"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{NullableEnumModelBinder}"/> interface.</param>
+ public NullableEnumModelBinder(ILogger<NullableEnumModelBinder> logger)
+ {
+ _logger = logger;
+ }
+
+ /// <inheritdoc />
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+ var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
+ var converter = TypeDescriptor.GetConverter(elementType);
+ if (valueProviderResult.Length != 0)
+ {
+ try
+ {
+ var convertedValue = converter.ConvertFromString(valueProviderResult.FirstValue);
+ bindingContext.Result = ModelBindingResult.Success(convertedValue);
+ }
+ catch (FormatException e)
+ {
+ _logger.LogWarning(e, "Error converting value.");
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs
new file mode 100644
index 000000000..bc12ad05d
--- /dev/null
+++ b/Jellyfin.Api/ModelBinders/NullableEnumModelBinderProvider.cs
@@ -0,0 +1,27 @@
+using System;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+ /// <summary>
+ /// Nullable enum model binder provider.
+ /// </summary>
+ public class NullableEnumModelBinderProvider : IModelBinderProvider
+ {
+ /// <inheritdoc />
+ public IModelBinder? GetBinder(ModelBinderProviderContext context)
+ {
+ var nullableType = Nullable.GetUnderlyingType(context.Metadata.ModelType);
+ if (nullableType == null || !nullableType.IsEnum)
+ {
+ // Type isn't nullable or isn't an enum.
+ return null;
+ }
+
+ var logger = context.Services.GetRequiredService<ILogger<NullableEnumModelBinder>>();
+ return new NullableEnumModelBinder(logger);
+ }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs
new file mode 100644
index 000000000..a42e0e4da
--- /dev/null
+++ b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs
@@ -0,0 +1,90 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Api.ModelBinders
+{
+ /// <summary>
+ /// Comma delimited array model binder.
+ /// Returns an empty array of specified type if there is no query parameter.
+ /// </summary>
+ public class PipeDelimitedArrayModelBinder : IModelBinder
+ {
+ private readonly ILogger<PipeDelimitedArrayModelBinder> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PipeDelimitedArrayModelBinder"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{PipeDelimitedArrayModelBinder}"/> interface.</param>
+ public PipeDelimitedArrayModelBinder(ILogger<PipeDelimitedArrayModelBinder> logger)
+ {
+ _logger = logger;
+ }
+
+ /// <inheritdoc/>
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+ var elementType = bindingContext.ModelType.GetElementType() ?? bindingContext.ModelType.GenericTypeArguments[0];
+ var converter = TypeDescriptor.GetConverter(elementType);
+
+ if (valueProviderResult.Length > 1)
+ {
+ var typedValues = GetParsedResult(valueProviderResult.Values, elementType, converter);
+ bindingContext.Result = ModelBindingResult.Success(typedValues);
+ }
+ else
+ {
+ var value = valueProviderResult.FirstValue;
+
+ if (value != null)
+ {
+ var splitValues = value.Split('|', StringSplitOptions.RemoveEmptyEntries);
+ var typedValues = GetParsedResult(splitValues, elementType, converter);
+ bindingContext.Result = ModelBindingResult.Success(typedValues);
+ }
+ else
+ {
+ var emptyResult = Array.CreateInstance(elementType, 0);
+ bindingContext.Result = ModelBindingResult.Success(emptyResult);
+ }
+ }
+
+ return Task.CompletedTask;
+ }
+
+ private Array GetParsedResult(IReadOnlyList<string> values, Type elementType, TypeConverter converter)
+ {
+ var parsedValues = new object?[values.Count];
+ var convertedCount = 0;
+ for (var i = 0; i < values.Count; i++)
+ {
+ try
+ {
+ parsedValues[i] = converter.ConvertFromString(values[i].Trim());
+ convertedCount++;
+ }
+ catch (FormatException e)
+ {
+ _logger.LogWarning(e, "Error converting value.");
+ }
+ }
+
+ var typedValues = Array.CreateInstance(elementType, convertedCount);
+ var typedValueIndex = 0;
+ for (var i = 0; i < parsedValues.Length; i++)
+ {
+ if (parsedValues[i] != null)
+ {
+ typedValues.SetValue(parsedValues[i], typedValueIndex);
+ typedValueIndex++;
+ }
+ }
+
+ return typedValues;
+ }
+ }
+}
diff --git a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
index 5ca4408d1..a47ae926c 100644
--- a/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
+++ b/Jellyfin.Api/Models/LiveTvDtos/GetProgramsDto.cs
@@ -16,7 +16,8 @@ namespace Jellyfin.Api.Models.LiveTvDtos
/// <summary>
/// Gets or sets the channels to return guide information for.
/// </summary>
- public string? ChannelIds { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<Guid> ChannelIds { get; set; } = Array.Empty<Guid>();
/// <summary>
/// Gets or sets optional. Filter by user id.
@@ -115,12 +116,14 @@ namespace Jellyfin.Api.Models.LiveTvDtos
/// <summary>
/// Gets or sets the genres to return guide information for.
/// </summary>
- public string? Genres { get; set; }
+ [JsonConverter(typeof(JsonPipeDelimitedArrayConverterFactory))]
+ public IReadOnlyList<string> Genres { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the genre ids to return guide information for.
/// </summary>
- public string? GenreIds { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<Guid> GenreIds { get; set; } = Array.Empty<Guid>();
/// <summary>
/// Gets or sets include image information in output.
diff --git a/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs
new file mode 100644
index 000000000..2cfdba507
--- /dev/null
+++ b/Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs
@@ -0,0 +1,86 @@
+using System;
+using MediaBrowser.Model.Dlna;
+
+namespace Jellyfin.Api.Models.MediaInfoDtos
+{
+ /// <summary>
+ /// Plabyback info dto.
+ /// </summary>
+ public class PlaybackInfoDto
+ {
+ /// <summary>
+ /// Gets or sets the playback userId.
+ /// </summary>
+ public Guid? UserId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the max streaming bitrate.
+ /// </summary>
+ public int? MaxStreamingBitrate { get; set; }
+
+ /// <summary>
+ /// Gets or sets the start time in ticks.
+ /// </summary>
+ public long? StartTimeTicks { get; set; }
+
+ /// <summary>
+ /// Gets or sets the audio stream index.
+ /// </summary>
+ public int? AudioStreamIndex { get; set; }
+
+ /// <summary>
+ /// Gets or sets the subtitle stream index.
+ /// </summary>
+ public int? SubtitleStreamIndex { get; set; }
+
+ /// <summary>
+ /// Gets or sets the max audio channels.
+ /// </summary>
+ public int? MaxAudioChannels { get; set; }
+
+ /// <summary>
+ /// Gets or sets the media source id.
+ /// </summary>
+ public string? MediaSourceId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the live stream id.
+ /// </summary>
+ public string? LiveStreamId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the device profile.
+ /// </summary>
+ public DeviceProfile? DeviceProfile { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to enable direct play.
+ /// </summary>
+ public bool? EnableDirectPlay { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to enable direct stream.
+ /// </summary>
+ public bool? EnableDirectStream { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to enable transcoding.
+ /// </summary>
+ public bool? EnableTranscoding { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to enable video stream copy.
+ /// </summary>
+ public bool? AllowVideoStreamCopy { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to allow audio stream copy.
+ /// </summary>
+ public bool? AllowAudioStreamCopy { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to auto open the live stream.
+ /// </summary>
+ public bool? AutoOpenLiveStream { get; set; }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
index 0d67c86f7..d0d6889fc 100644
--- a/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
+++ b/Jellyfin.Api/Models/PlaylistDtos/CreatePlaylistDto.cs
@@ -1,4 +1,7 @@
using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
namespace Jellyfin.Api.Models.PlaylistDtos
{
@@ -15,7 +18,8 @@ namespace Jellyfin.Api.Models.PlaylistDtos
/// <summary>
/// Gets or sets item ids to add to the playlist.
/// </summary>
- public string? Ids { get; set; }
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<Guid> Ids { get; set; } = Array.Empty<Guid>();
/// <summary>
/// Gets or sets the user id.
diff --git a/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
new file mode 100644
index 000000000..e58095536
--- /dev/null
+++ b/Jellyfin.Api/Models/SessionDtos/ClientCapabilitiesDto.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using MediaBrowser.Common.Json.Converters;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Session;
+
+namespace Jellyfin.Api.Models.SessionDtos
+{
+ /// <summary>
+ /// Client capabilities dto.
+ /// </summary>
+ public class ClientCapabilitiesDto
+ {
+ /// <summary>
+ /// Gets or sets the list of playable media types.
+ /// </summary>
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<string> PlayableMediaTypes { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the list of supported commands.
+ /// </summary>
+ [JsonConverter(typeof(JsonCommaDelimitedArrayConverterFactory))]
+ public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; } = Array.Empty<GeneralCommandType>();
+
+ /// <summary>
+ /// Gets or sets a value indicating whether session supports media control.
+ /// </summary>
+ public bool SupportsMediaControl { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether session supports content uploading.
+ /// </summary>
+ public bool SupportsContentUploading { get; set; }
+
+ /// <summary>
+ /// Gets or sets the message callback url.
+ /// </summary>
+ public string? MessageCallbackUrl { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether session supports a persistent identifier.
+ /// </summary>
+ public bool SupportsPersistentIdentifier { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether session supports sync.
+ /// </summary>
+ public bool SupportsSync { get; set; }
+
+ /// <summary>
+ /// Gets or sets the device profile.
+ /// </summary>
+ public DeviceProfile? DeviceProfile { get; set; }
+
+ /// <summary>
+ /// Gets or sets the app store url.
+ /// </summary>
+ public string? AppStoreUrl { get; set; }
+
+ /// <summary>
+ /// Gets or sets the icon url.
+ /// </summary>
+ public string? IconUrl { get; set; }
+
+ /// <summary>
+ /// Convert the dto to the full <see cref="ClientCapabilities"/> model.
+ /// </summary>
+ /// <returns>The converted <see cref="ClientCapabilities"/> model.</returns>
+ public ClientCapabilities ToClientCapabilities()
+ {
+ return new ClientCapabilities
+ {
+ PlayableMediaTypes = PlayableMediaTypes,
+ SupportedCommands = SupportedCommands,
+ SupportsMediaControl = SupportsMediaControl,
+ SupportsContentUploading = SupportsContentUploading,
+ MessageCallbackUrl = MessageCallbackUrl,
+ SupportsPersistentIdentifier = SupportsPersistentIdentifier,
+ SupportsSync = SupportsSync,
+ DeviceProfile = DeviceProfile,
+ AppStoreUrl = AppStoreUrl,
+ IconUrl = IconUrl
+ };
+ }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs
new file mode 100644
index 000000000..479c44084
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs
@@ -0,0 +1,42 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class BufferRequestDto.
+ /// </summary>
+ public class BufferRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BufferRequestDto"/> class.
+ /// </summary>
+ public BufferRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets when the request has been made by the client.
+ /// </summary>
+ /// <value>The date of the request.</value>
+ public DateTime When { get; set; }
+
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the client playback is unpaused.
+ /// </summary>
+ /// <value>The client playback status.</value>
+ public bool IsPlaying { get; set; }
+
+ /// <summary>
+ /// Gets or sets the playlist item identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist item identifier.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs
new file mode 100644
index 000000000..4c30b7be4
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs
@@ -0,0 +1,14 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class IgnoreWaitRequestDto.
+ /// </summary>
+ public class IgnoreWaitRequestDto
+ {
+ /// <summary>
+ /// Gets or sets a value indicating whether the client should be ignored.
+ /// </summary>
+ /// <value>The client group-wait status.</value>
+ public bool IgnoreWait { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs
new file mode 100644
index 000000000..ed97b8d6a
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs
@@ -0,0 +1,16 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class JoinGroupRequestDto.
+ /// </summary>
+ public class JoinGroupRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the group identifier.
+ /// </summary>
+ /// <value>The identifier of the group to join.</value>
+ public Guid GroupId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs
new file mode 100644
index 000000000..3af25f3e3
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs
@@ -0,0 +1,30 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class MovePlaylistItemRequestDto.
+ /// </summary>
+ public class MovePlaylistItemRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MovePlaylistItemRequestDto"/> class.
+ /// </summary>
+ public MovePlaylistItemRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the playlist identifier of the item.
+ /// </summary>
+ /// <value>The playlist identifier of the item.</value>
+ public Guid PlaylistItemId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the new position.
+ /// </summary>
+ /// <value>The new position.</value>
+ public int NewIndex { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs
new file mode 100644
index 000000000..441d7be36
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs
@@ -0,0 +1,22 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class NewGroupRequestDto.
+ /// </summary>
+ public class NewGroupRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NewGroupRequestDto"/> class.
+ /// </summary>
+ public NewGroupRequestDto()
+ {
+ GroupName = string.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the group name.
+ /// </summary>
+ /// <value>The name of the new group.</value>
+ public string GroupName { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs
new file mode 100644
index 000000000..f59a93f13
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class NextItemRequestDto.
+ /// </summary>
+ public class NextItemRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NextItemRequestDto"/> class.
+ /// </summary>
+ public NextItemRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the playing item identifier.
+ /// </summary>
+ /// <value>The playing item identifier.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs
new file mode 100644
index 000000000..c4ac06856
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs
@@ -0,0 +1,14 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class PingRequestDto.
+ /// </summary>
+ public class PingRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the ping time.
+ /// </summary>
+ /// <value>The ping time.</value>
+ public long Ping { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs
new file mode 100644
index 000000000..844388cd9
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class PlayRequestDto.
+ /// </summary>
+ public class PlayRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayRequestDto"/> class.
+ /// </summary>
+ public PlayRequestDto()
+ {
+ PlayingQueue = Array.Empty<Guid>();
+ }
+
+ /// <summary>
+ /// Gets or sets the playing queue.
+ /// </summary>
+ /// <value>The playing queue.</value>
+ public IReadOnlyList<Guid> PlayingQueue { get; set; }
+
+ /// <summary>
+ /// Gets or sets the position of the playing item in the queue.
+ /// </summary>
+ /// <value>The playing item position.</value>
+ public int PlayingItemPosition { get; set; }
+
+ /// <summary>
+ /// Gets or sets the start position ticks.
+ /// </summary>
+ /// <value>The start position ticks.</value>
+ public long StartPositionTicks { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs
new file mode 100644
index 000000000..7fd4a49be
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class PreviousItemRequestDto.
+ /// </summary>
+ public class PreviousItemRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PreviousItemRequestDto"/> class.
+ /// </summary>
+ public PreviousItemRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the playing item identifier.
+ /// </summary>
+ /// <value>The playing item identifier.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs
new file mode 100644
index 000000000..2b187f443
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Model.SyncPlay;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class QueueRequestDto.
+ /// </summary>
+ public class QueueRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="QueueRequestDto"/> class.
+ /// </summary>
+ public QueueRequestDto()
+ {
+ ItemIds = Array.Empty<Guid>();
+ }
+
+ /// <summary>
+ /// Gets or sets the items to enqueue.
+ /// </summary>
+ /// <value>The items to enqueue.</value>
+ public IReadOnlyList<Guid> ItemIds { get; set; }
+
+ /// <summary>
+ /// Gets or sets the mode in which to add the new items.
+ /// </summary>
+ /// <value>The enqueue mode.</value>
+ public GroupQueueMode Mode { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs
new file mode 100644
index 000000000..d9c193016
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs
@@ -0,0 +1,42 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class ReadyRequest.
+ /// </summary>
+ public class ReadyRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ReadyRequestDto"/> class.
+ /// </summary>
+ public ReadyRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets when the request has been made by the client.
+ /// </summary>
+ /// <value>The date of the request.</value>
+ public DateTime When { get; set; }
+
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the client playback is unpaused.
+ /// </summary>
+ /// <value>The client playback status.</value>
+ public bool IsPlaying { get; set; }
+
+ /// <summary>
+ /// Gets or sets the playlist item identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist item identifier.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs
new file mode 100644
index 000000000..e9b2b2cb3
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class RemoveFromPlaylistRequestDto.
+ /// </summary>
+ public class RemoveFromPlaylistRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RemoveFromPlaylistRequestDto"/> class.
+ /// </summary>
+ public RemoveFromPlaylistRequestDto()
+ {
+ PlaylistItemIds = Array.Empty<Guid>();
+ }
+
+ /// <summary>
+ /// Gets or sets the playlist identifiers ot the items.
+ /// </summary>
+ /// <value>The playlist identifiers ot the items.</value>
+ public IReadOnlyList<Guid> PlaylistItemIds { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs
new file mode 100644
index 000000000..b9af0be7f
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs
@@ -0,0 +1,14 @@
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class SeekRequestDto.
+ /// </summary>
+ public class SeekRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs
new file mode 100644
index 000000000..b937679fc
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs
@@ -0,0 +1,24 @@
+using System;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class SetPlaylistItemRequestDto.
+ /// </summary>
+ public class SetPlaylistItemRequestDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SetPlaylistItemRequestDto"/> class.
+ /// </summary>
+ public SetPlaylistItemRequestDto()
+ {
+ PlaylistItemId = Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets or sets the playlist identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist identifier of the playing item.</value>
+ public Guid PlaylistItemId { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs
new file mode 100644
index 000000000..e748fc3e0
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs
@@ -0,0 +1,16 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class SetRepeatModeRequestDto.
+ /// </summary>
+ public class SetRepeatModeRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the repeat mode.
+ /// </summary>
+ /// <value>The repeat mode.</value>
+ public GroupRepeatMode Mode { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs
new file mode 100644
index 000000000..0e427f4a4
--- /dev/null
+++ b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs
@@ -0,0 +1,16 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace Jellyfin.Api.Models.SyncPlayDtos
+{
+ /// <summary>
+ /// Class SetShuffleModeRequestDto.
+ /// </summary>
+ public class SetShuffleModeRequestDto
+ {
+ /// <summary>
+ /// Gets or sets the shuffle mode.
+ /// </summary>
+ /// <value>The shuffle mode.</value>
+ public GroupShuffleMode Mode { get; set; }
+ }
+}
diff --git a/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs b/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs
deleted file mode 100644
index db55dc34b..000000000
--- a/Jellyfin.Api/Models/VideoDtos/DeviceProfileDto.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using MediaBrowser.Model.Dlna;
-
-namespace Jellyfin.Api.Models.VideoDtos
-{
- /// <summary>
- /// Device profile dto.
- /// </summary>
- public class DeviceProfileDto
- {
- /// <summary>
- /// Gets or sets device profile.
- /// </summary>
- public DeviceProfile? DeviceProfile { get; set; }
- }
-}
diff --git a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs b/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs
deleted file mode 100644
index 315b47329..000000000
--- a/Jellyfin.Api/TypeConverters/DateTimeTypeConverter.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System;
-using System.ComponentModel;
-using System.Globalization;
-
-namespace Jellyfin.Api.TypeConverters
-{
- /// <summary>
- /// Custom datetime parser.
- /// </summary>
- public class DateTimeTypeConverter : TypeConverter
- {
- /// <inheritdoc />
- public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
- {
- if (sourceType == typeof(string))
- {
- return true;
- }
-
- return base.CanConvertFrom(context, sourceType);
- }
-
- /// <inheritdoc />
- public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
- {
- if (value is string dateString)
- {
- // Mark Played Item.
- if (DateTime.TryParseExact(dateString, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dateTime))
- {
- return dateTime;
- }
-
- // Get Activity Logs.
- if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out dateTime))
- {
- return dateTime;
- }
- }
-
- return base.ConvertFrom(context, culture, value);
- }
- }
-}
diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs
index ce5465116..288e03fcf 100644
--- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs
+++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs
@@ -58,7 +58,7 @@ namespace Jellyfin.Api.WebSocketListeners
private void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e)
{
- SendData(true);
+ SendData(true).GetAwaiter().GetResult();
}
}
}
diff --git a/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs b/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs
new file mode 100644
index 000000000..511e3b281
--- /dev/null
+++ b/Jellyfin.Data/Entities/CustomItemDisplayPreferences.cs
@@ -0,0 +1,90 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Jellyfin.Data.Entities
+{
+ /// <summary>
+ /// An entity that represents a user's custom display preferences for a specific item.
+ /// </summary>
+ public class CustomItemDisplayPreferences
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CustomItemDisplayPreferences"/> class.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="client">The client.</param>
+ /// <param name="preferenceKey">The preference key.</param>
+ /// <param name="preferenceValue">The preference value.</param>
+ public CustomItemDisplayPreferences(Guid userId, Guid itemId, string client, string preferenceKey, string preferenceValue)
+ {
+ UserId = userId;
+ ItemId = itemId;
+ Client = client;
+ Key = preferenceKey;
+ Value = preferenceValue;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="CustomItemDisplayPreferences"/> class.
+ /// </summary>
+ protected CustomItemDisplayPreferences()
+ {
+ }
+
+ /// <summary>
+ /// Gets or sets the Id.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int Id { get; protected set; }
+
+ /// <summary>
+ /// Gets or sets the user Id.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ public Guid UserId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the id of the associated item.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ public Guid ItemId { get; set; }
+
+ /// <summary>
+ /// Gets or sets the client string.
+ /// </summary>
+ /// <remarks>
+ /// Required. Max Length = 32.
+ /// </remarks>
+ [Required]
+ [MaxLength(32)]
+ [StringLength(32)]
+ public string Client { get; set; }
+
+ /// <summary>
+ /// Gets or sets the preference key.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ [Required]
+ public string Key { get; set; }
+
+ /// <summary>
+ /// Gets or sets the preference value.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ [Required]
+ public string Value { get; set; }
+ }
+}
diff --git a/Jellyfin.Data/Entities/DisplayPreferences.cs b/Jellyfin.Data/Entities/DisplayPreferences.cs
index 701e4df00..1a8ca1da3 100644
--- a/Jellyfin.Data/Entities/DisplayPreferences.cs
+++ b/Jellyfin.Data/Entities/DisplayPreferences.cs
@@ -17,10 +17,12 @@ namespace Jellyfin.Data.Entities
/// Initializes a new instance of the <see cref="DisplayPreferences"/> class.
/// </summary>
/// <param name="userId">The user's id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="client">The client string.</param>
- public DisplayPreferences(Guid userId, string client)
+ public DisplayPreferences(Guid userId, Guid itemId, string client)
{
UserId = userId;
+ ItemId = itemId;
Client = client;
ShowSidebar = false;
ShowBackdrop = true;
@@ -59,6 +61,14 @@ namespace Jellyfin.Data.Entities
public Guid UserId { get; set; }
/// <summary>
+ /// Gets or sets the id of the associated item.
+ /// </summary>
+ /// <remarks>
+ /// Required.
+ /// </remarks>
+ public Guid ItemId { get; set; }
+
+ /// <summary>
/// Gets or sets the client string.
/// </summary>
/// <remarks>
diff --git a/Jellyfin.Data/Entities/Libraries/CollectionItem.cs b/Jellyfin.Data/Entities/Libraries/CollectionItem.cs
index 4467c9bbd..f9539964d 100644
--- a/Jellyfin.Data/Entities/Libraries/CollectionItem.cs
+++ b/Jellyfin.Data/Entities/Libraries/CollectionItem.cs
@@ -73,7 +73,7 @@ namespace Jellyfin.Data.Entities.Libraries
/// Gets or sets the next item in the collection.
/// </summary>
/// <remarks>
- /// TODO check if this properly updated dependant and has the proper principal relationship.
+ /// TODO check if this properly updated Dependant and has the proper principal relationship.
/// </remarks>
public virtual CollectionItem Next { get; set; }
@@ -81,7 +81,7 @@ namespace Jellyfin.Data.Entities.Libraries
/// Gets or sets the previous item in the collection.
/// </summary>
/// <remarks>
- /// TODO check if this properly updated dependant and has the proper principal relationship.
+ /// TODO check if this properly updated Dependant and has the proper principal relationship.
/// </remarks>
public virtual CollectionItem Previous { get; set; }
diff --git a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs b/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs
index 1d2dc0f66..d74330c05 100644
--- a/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs
+++ b/Jellyfin.Data/Entities/Libraries/ItemMetadata.cs
@@ -141,7 +141,7 @@ namespace Jellyfin.Data.Entities.Libraries
public virtual ICollection<PersonRole> PersonRoles { get; protected set; }
/// <summary>
- /// Gets or sets a collection containing the generes for this item.
+ /// Gets or sets a collection containing the genres for this item.
/// </summary>
public virtual ICollection<Genre> Genres { get; protected set; }
diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj
index 9ae129d07..89d6f4d9b 100644
--- a/Jellyfin.Data/Jellyfin.Data.csproj
+++ b/Jellyfin.Data/Jellyfin.Data.csproj
@@ -29,7 +29,7 @@
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
+ <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
</ItemGroup>
<!-- Code analysers-->
diff --git a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs
new file mode 100644
index 000000000..df420f48a
--- /dev/null
+++ b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs
@@ -0,0 +1,221 @@
+#pragma warning disable CA1819 // Properties should not return arrays
+
+using System;
+using MediaBrowser.Model.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+ /// <summary>
+ /// Defines the <see cref="NetworkConfiguration" />.
+ /// </summary>
+ public class NetworkConfiguration
+ {
+ /// <summary>
+ /// The default value for <see cref="HttpServerPortNumber"/>.
+ /// </summary>
+ public const int DefaultHttpPort = 8096;
+
+ /// <summary>
+ /// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>.
+ /// </summary>
+ public const int DefaultHttpsPort = 8920;
+
+ private string _baseUrl = string.Empty;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the server should force connections over HTTPS.
+ /// </summary>
+ public bool RequireHttps { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at.
+ /// </summary>
+ public string BaseUrl
+ {
+ get => _baseUrl;
+ set
+ {
+ // Normalize the start of the string
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ // If baseUrl is empty, set an empty prefix string
+ _baseUrl = string.Empty;
+ return;
+ }
+
+ if (value[0] != '/')
+ {
+ // If baseUrl was not configured with a leading slash, append one for consistency
+ value = "/" + value;
+ }
+
+ // Normalize the end of the string
+ if (value[^1] == '/')
+ {
+ // If baseUrl was configured with a trailing slash, remove it for consistency
+ value = value.Remove(value.Length - 1);
+ }
+
+ _baseUrl = value;
+ }
+ }
+
+ /// <summary>
+ /// Gets or sets the public HTTPS port.
+ /// </summary>
+ /// <value>The public HTTPS port.</value>
+ public int PublicHttpsPort { get; set; } = DefaultHttpsPort;
+
+ /// <summary>
+ /// Gets or sets the HTTP server port number.
+ /// </summary>
+ /// <value>The HTTP server port number.</value>
+ public int HttpServerPortNumber { get; set; } = DefaultHttpPort;
+
+ /// <summary>
+ /// Gets or sets the HTTPS server port number.
+ /// </summary>
+ /// <value>The HTTPS server port number.</value>
+ public int HttpsPortNumber { get; set; } = DefaultHttpsPort;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to use HTTPS.
+ /// </summary>
+ /// <remarks>
+ /// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
+ /// provided for <see cref="ServerConfiguration.CertificatePath"/> and <see cref="ServerConfiguration.CertificatePassword"/>.
+ /// </remarks>
+ public bool EnableHttps { get; set; }
+
+ /// <summary>
+ /// Gets or sets the public mapped port.
+ /// </summary>
+ /// <value>The public mapped port.</value>
+ public int PublicPort { get; set; } = DefaultHttpPort;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding.
+ /// </summary>
+ public bool UPnPCreateHttpPortMap { get; set; }
+
+ /// <summary>
+ /// Gets or sets the UDPPortRange.
+ /// </summary>
+ public string UDPPortRange { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether gets or sets IPV6 capability.
+ /// </summary>
+ public bool EnableIPV6 { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether gets or sets IPV4 capability.
+ /// </summary>
+ public bool EnableIPV4 { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether detailed SSDP logs are sent to the console/log.
+ /// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to have any effect.
+ /// </summary>
+ public bool EnableSSDPTracing { get; set; }
+
+ /// <summary>
+ /// Gets or sets the SSDPTracingFilter
+ /// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being sent to the console/log.
+ /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work.
+ /// </summary>
+ public string SSDPTracingFilter { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the number of times SSDP UDP messages are sent.
+ /// </summary>
+ public int UDPSendCount { get; set; } = 2;
+
+ /// <summary>
+ /// Gets or sets the delay between each groups of SSDP messages (in ms).
+ /// </summary>
+ public int UDPSendDelay { get; set; } = 100;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding.
+ /// </summary>
+ public bool IgnoreVirtualInterfaces { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>.
+ /// </summary>
+ public string VirtualInterfaceNames { get; set; } = "vEthernet*";
+
+ /// <summary>
+ /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor.
+ /// </summary>
+ public int GatewayMonitorPeriod { get; set; } = 60;
+
+ /// <summary>
+ /// Gets a value indicating whether multi-socket binding is available.
+ /// </summary>
+ public bool EnableMultiSocketBinding { get; } = true;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network.
+ /// Depending on the address range implemented ULA ranges might not be used.
+ /// </summary>
+ public bool TrustAllIP6Interfaces { get; set; }
+
+ /// <summary>
+ /// Gets or sets the ports that HDHomerun uses.
+ /// </summary>
+ public string HDHomerunPortRange { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the PublishedServerUriBySubnet
+ /// Gets or sets PublishedServerUri to advertise for specific subnets.
+ /// </summary>
+ public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets a value indicating whether Autodiscovery tracing is enabled.
+ /// </summary>
+ public bool AutoDiscoveryTracing { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether Autodiscovery is enabled.
+ /// </summary>
+ public bool AutoDiscovery { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with <seealso cref="IsRemoteIPFilterBlacklist"/>.
+ /// </summary>
+ public string[] RemoteIPFilter { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist.
+ /// </summary>
+ public bool IsRemoteIPFilterBlacklist { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether to enable automatic port forwarding.
+ /// </summary>
+ public bool EnableUPnP { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether access outside of the LAN is permitted.
+ /// </summary>
+ public bool EnableRemoteAccess { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets the subnets that are deemed to make up the LAN.
+ /// </summary>
+ public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used.
+ /// </summary>
+ public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets the known proxies.
+ /// </summary>
+ public string[] KnownProxies { get; set; } = Array.Empty<string>();
+ }
+}
diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs
new file mode 100644
index 000000000..e77b17ba9
--- /dev/null
+++ b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs
@@ -0,0 +1,21 @@
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+ /// <summary>
+ /// Defines the <see cref="NetworkConfigurationExtensions" />.
+ /// </summary>
+ public static class NetworkConfigurationExtensions
+ {
+ /// <summary>
+ /// Retrieves the network configuration.
+ /// </summary>
+ /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
+ /// <returns>The <see cref="NetworkConfiguration"/>.</returns>
+ public static NetworkConfiguration GetNetworkConfiguration(this IConfigurationManager config)
+ {
+ return config.GetConfiguration<NetworkConfiguration>("network");
+ }
+ }
+}
diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs
new file mode 100644
index 000000000..ac0485d87
--- /dev/null
+++ b/Jellyfin.Networking/Configuration/NetworkConfigurationFactory.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using MediaBrowser.Common.Configuration;
+
+namespace Jellyfin.Networking.Configuration
+{
+ /// <summary>
+ /// Defines the <see cref="NetworkConfigurationFactory" />.
+ /// </summary>
+ public class NetworkConfigurationFactory : IConfigurationFactory
+ {
+ /// <summary>
+ /// The GetConfigurations.
+ /// </summary>
+ /// <returns>The <see cref="IEnumerable{ConfigurationStore}"/>.</returns>
+ public IEnumerable<ConfigurationStore> GetConfigurations()
+ {
+ return new[]
+ {
+ new ConfigurationStore
+ {
+ Key = "network",
+ ConfigurationType = typeof(NetworkConfiguration)
+ }
+ };
+ }
+ }
+}
diff --git a/Jellyfin.Networking/Jellyfin.Networking.csproj b/Jellyfin.Networking/Jellyfin.Networking.csproj
new file mode 100644
index 000000000..cbda74361
--- /dev/null
+++ b/Jellyfin.Networking/Jellyfin.Networking.csproj
@@ -0,0 +1,30 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net5.0</TargetFramework>
+ <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Compile Include="..\SharedVersion.cs" />
+ </ItemGroup>
+
+ <!-- Code Analyzers-->
+ <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
+ <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
+ <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
+ <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+ </ItemGroup>
+
+ <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
+ <ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/Jellyfin.Networking/Manager/NetworkManager.cs b/Jellyfin.Networking/Manager/NetworkManager.cs
new file mode 100644
index 000000000..85da927fb
--- /dev/null
+++ b/Jellyfin.Networking/Manager/NetworkManager.cs
@@ -0,0 +1,1323 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Networking.Manager
+{
+ /// <summary>
+ /// Class to take care of network interface management.
+ /// Note: The normal collection methods and properties will not work with Collection{IPObject}. <see cref="MediaBrowser.Common.Net.NetworkExtensions"/>.
+ /// </summary>
+ public class NetworkManager : INetworkManager, IDisposable
+ {
+ /// <summary>
+ /// Contains the description of the interface along with its index.
+ /// </summary>
+ private readonly Dictionary<string, int> _interfaceNames;
+
+ /// <summary>
+ /// Threading lock for network properties.
+ /// </summary>
+ private readonly object _intLock = new object();
+
+ /// <summary>
+ /// List of all interface addresses and masks.
+ /// </summary>
+ private readonly Collection<IPObject> _interfaceAddresses;
+
+ /// <summary>
+ /// List of all interface MAC addresses.
+ /// </summary>
+ private readonly List<PhysicalAddress> _macAddresses;
+
+ private readonly ILogger<NetworkManager> _logger;
+
+ private readonly IConfigurationManager _configurationManager;
+
+ private readonly object _eventFireLock;
+
+ /// <summary>
+ /// Holds the bind address overrides.
+ /// </summary>
+ private readonly Dictionary<IPNetAddress, string> _publishedServerUrls;
+
+ /// <summary>
+ /// Used to stop "event-racing conditions".
+ /// </summary>
+ private bool _eventfire;
+
+ /// <summary>
+ /// Unfiltered user defined LAN subnets. (<see cref="NetworkConfiguration.LocalNetworkSubnets"/>)
+ /// or internal interface network subnets if undefined by user.
+ /// </summary>
+ private Collection<IPObject> _lanSubnets;
+
+ /// <summary>
+ /// User defined list of subnets to excluded from the LAN.
+ /// </summary>
+ private Collection<IPObject> _excludedSubnets;
+
+ /// <summary>
+ /// List of interface addresses to bind the WS.
+ /// </summary>
+ private Collection<IPObject> _bindAddresses;
+
+ /// <summary>
+ /// List of interface addresses to exclude from bind.
+ /// </summary>
+ private Collection<IPObject> _bindExclusions;
+
+ /// <summary>
+ /// Caches list of all internal filtered interface addresses and masks.
+ /// </summary>
+ private Collection<IPObject> _internalInterfaces;
+
+ /// <summary>
+ /// Flag set when no custom LAN has been defined in the configuration.
+ /// </summary>
+ private bool _usingPrivateAddresses;
+
+ /// <summary>
+ /// True if this object is disposed.
+ /// </summary>
+ private bool _disposed;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NetworkManager"/> class.
+ /// </summary>
+ /// <param name="configurationManager">IServerConfigurationManager instance.</param>
+ /// <param name="logger">Logger to use for messages.</param>
+#pragma warning disable CS8618 // Non-nullable field is uninitialized. : Values are set in UpdateSettings function. Compiler doesn't yet recognise this.
+ public NetworkManager(IConfigurationManager configurationManager, ILogger<NetworkManager> logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager));
+
+ _interfaceAddresses = new Collection<IPObject>();
+ _macAddresses = new List<PhysicalAddress>();
+ _interfaceNames = new Dictionary<string, int>();
+ _publishedServerUrls = new Dictionary<IPNetAddress, string>();
+ _eventFireLock = new object();
+
+ UpdateSettings(_configurationManager.GetNetworkConfiguration());
+
+ NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
+ NetworkChange.NetworkAvailabilityChanged += OnNetworkAvailabilityChanged;
+
+ _configurationManager.NamedConfigurationUpdated += ConfigurationUpdated;
+ }
+#pragma warning restore CS8618 // Non-nullable field is uninitialized.
+
+ /// <summary>
+ /// Event triggered on network changes.
+ /// </summary>
+ public event EventHandler? NetworkChanged;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether testing is taking place.
+ /// </summary>
+ public static string MockNetworkSettings { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether IP6 is enabled.
+ /// </summary>
+ public bool IsIP6Enabled { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether IP4 is enabled.
+ /// </summary>
+ public bool IsIP4Enabled { get; set; }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> RemoteAddressFilter { get; private set; }
+
+ /// <summary>
+ /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal.
+ /// </summary>
+ public bool TrustAllIP6Interfaces { get; internal set; }
+
+ /// <summary>
+ /// Gets the Published server override list.
+ /// </summary>
+ public Dictionary<IPNetAddress, string> PublishedServerUrls => _publishedServerUrls;
+
+ /// <summary>
+ /// Creates a new network collection.
+ /// </summary>
+ /// <param name="source">Items to assign the collection, or null.</param>
+ /// <returns>The collection created.</returns>
+ public static Collection<IPObject> CreateCollection(IEnumerable<IPObject>? source = null)
+ {
+ var result = new Collection<IPObject>();
+ if (source != null)
+ {
+ foreach (var item in source)
+ {
+ result.AddItem(item);
+ }
+ }
+
+ return result;
+ }
+
+ /// <inheritdoc/>
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <inheritdoc/>
+ public IReadOnlyCollection<PhysicalAddress> GetMacAddresses()
+ {
+ // Populated in construction - so always has values.
+ return _macAddresses;
+ }
+
+ /// <inheritdoc/>
+ public bool IsGatewayInterface(IPObject? addressObj)
+ {
+ var address = addressObj?.Address ?? IPAddress.None;
+ return _internalInterfaces.Any(i => i.Address.Equals(address) && i.Tag < 0);
+ }
+
+ /// <inheritdoc/>
+ public bool IsGatewayInterface(IPAddress? addressObj)
+ {
+ return _internalInterfaces.Any(i => i.Address.Equals(addressObj ?? IPAddress.None) && i.Tag < 0);
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> GetLoopbacks()
+ {
+ Collection<IPObject> nc = new Collection<IPObject>();
+ if (IsIP4Enabled)
+ {
+ nc.AddItem(IPAddress.Loopback);
+ }
+
+ if (IsIP6Enabled)
+ {
+ nc.AddItem(IPAddress.IPv6Loopback);
+ }
+
+ return nc;
+ }
+
+ /// <inheritdoc/>
+ public bool IsExcluded(IPAddress ip)
+ {
+ return _excludedSubnets.ContainsAddress(ip);
+ }
+
+ /// <inheritdoc/>
+ public bool IsExcluded(EndPoint ip)
+ {
+ return ip != null && IsExcluded(((IPEndPoint)ip).Address);
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> CreateIPCollection(string[] values, bool negated = false)
+ {
+ Collection<IPObject> col = new Collection<IPObject>();
+ if (values == null)
+ {
+ return col;
+ }
+
+ for (int a = 0; a < values.Length; a++)
+ {
+ string v = values[a].Trim();
+
+ try
+ {
+ if (v.StartsWith('!'))
+ {
+ if (negated)
+ {
+ AddToCollection(col, v[1..]);
+ }
+ }
+ else if (!negated)
+ {
+ AddToCollection(col, v);
+ }
+ }
+ catch (ArgumentException e)
+ {
+ _logger.LogWarning(e, "Ignoring LAN value {Value}.", v);
+ }
+ }
+
+ return col;
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false)
+ {
+ int count = _bindAddresses.Count;
+
+ if (count == 0)
+ {
+ if (_bindExclusions.Count > 0)
+ {
+ // Return all the interfaces except the ones specifically excluded.
+ return _interfaceAddresses.Exclude(_bindExclusions);
+ }
+
+ if (individualInterfaces)
+ {
+ return new Collection<IPObject>(_interfaceAddresses);
+ }
+
+ // No bind address and no exclusions, so listen on all interfaces.
+ Collection<IPObject> result = new Collection<IPObject>();
+
+ if (IsIP4Enabled)
+ {
+ result.AddItem(IPAddress.Any);
+ }
+
+ if (IsIP6Enabled)
+ {
+ result.AddItem(IPAddress.IPv6Any);
+ }
+
+ return result;
+ }
+
+ // Remove any excluded bind interfaces.
+ return _bindAddresses.Exclude(_bindExclusions);
+ }
+
+ /// <inheritdoc/>
+ public string GetBindInterface(string source, out int? port)
+ {
+ if (!string.IsNullOrEmpty(source) && IPHost.TryParse(source, out IPHost host))
+ {
+ return GetBindInterface(host, out port);
+ }
+
+ return GetBindInterface(IPHost.None, out port);
+ }
+
+ /// <inheritdoc/>
+ public string GetBindInterface(IPAddress source, out int? port)
+ {
+ return GetBindInterface(new IPNetAddress(source), out port);
+ }
+
+ /// <inheritdoc/>
+ public string GetBindInterface(HttpRequest source, out int? port)
+ {
+ string result;
+
+ if (source != null && IPHost.TryParse(source.Host.Host, out IPHost host))
+ {
+ result = GetBindInterface(host, out port);
+ port ??= source.Host.Port;
+ }
+ else
+ {
+ result = GetBindInterface(IPNetAddress.None, out port);
+ port ??= source?.Host.Port;
+ }
+
+ return result;
+ }
+
+ /// <inheritdoc/>
+ public string GetBindInterface(IPObject source, out int? port)
+ {
+ port = null;
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ // Do we have a source?
+ bool haveSource = !source.Address.Equals(IPAddress.None);
+ bool isExternal = false;
+
+ if (haveSource)
+ {
+ if (!IsIP6Enabled && source.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ _logger.LogWarning("IPv6 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected.");
+ }
+
+ if (!IsIP4Enabled && source.AddressFamily == AddressFamily.InterNetwork)
+ {
+ _logger.LogWarning("IPv4 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected.");
+ }
+
+ isExternal = !IsInLocalNetwork(source);
+
+ if (MatchesPublishedServerUrl(source, isExternal, out string res, out port))
+ {
+ _logger.LogInformation("{Source}: Using BindAddress {Address}:{Port}", source, res, port);
+ return res;
+ }
+ }
+
+ _logger.LogDebug("GetBindInterface: Source: {HaveSource}, External: {IsExternal}:", haveSource, isExternal);
+
+ // No preference given, so move on to bind addresses.
+ if (MatchesBindInterface(source, isExternal, out string result))
+ {
+ return result;
+ }
+
+ if (isExternal && MatchesExternalInterface(source, out result))
+ {
+ return result;
+ }
+
+ // Get the first LAN interface address that isn't a loopback.
+ var interfaces = CreateCollection(_interfaceAddresses
+ .Exclude(_bindExclusions)
+ .Where(p => IsInLocalNetwork(p))
+ .OrderBy(p => p.Tag));
+
+ if (interfaces.Count > 0)
+ {
+ if (haveSource)
+ {
+ // Does the request originate in one of the interface subnets?
+ // (For systems with multiple internal network cards, and multiple subnets)
+ foreach (var intf in interfaces)
+ {
+ if (intf.Contains(source))
+ {
+ result = FormatIP6String(intf.Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Has source, matched best internal interface on range. {Result}", source, result);
+ return result;
+ }
+ }
+ }
+
+ result = FormatIP6String(interfaces.First().Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Matched first internal interface. {Result}", source, result);
+ return result;
+ }
+
+ // There isn't any others, so we'll use the loopback.
+ result = IsIP6Enabled ? "::" : "127.0.0.1";
+ _logger.LogWarning("{Source}: GetBindInterface: Loopback {Result} returned.", source, result);
+ return result;
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> GetInternalBindAddresses()
+ {
+ int count = _bindAddresses.Count;
+
+ if (count == 0)
+ {
+ if (_bindExclusions.Count > 0)
+ {
+ // Return all the internal interfaces except the ones excluded.
+ return CreateCollection(_internalInterfaces.Where(p => !_bindExclusions.ContainsAddress(p)));
+ }
+
+ // No bind address, so return all internal interfaces.
+ return CreateCollection(_internalInterfaces.Where(p => !p.IsLoopback()));
+ }
+
+ return new Collection<IPObject>(_bindAddresses);
+ }
+
+ /// <inheritdoc/>
+ public bool IsInLocalNetwork(IPObject address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.Equals(IPAddress.None))
+ {
+ return false;
+ }
+
+ // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+ if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ return true;
+ }
+
+ // As private addresses can be redefined by Configuration.LocalNetworkAddresses
+ return _lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address);
+ }
+
+ /// <inheritdoc/>
+ public bool IsInLocalNetwork(string address)
+ {
+ if (IPHost.TryParse(address, out IPHost ep))
+ {
+ return _lanSubnets.ContainsAddress(ep) && !_excludedSubnets.ContainsAddress(ep);
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public bool IsInLocalNetwork(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+ if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ return true;
+ }
+
+ // As private addresses can be redefined by Configuration.LocalNetworkAddresses
+ return _lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address);
+ }
+
+ /// <inheritdoc/>
+ public bool IsPrivateAddressRange(IPObject address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ // See conversation at https://github.com/jellyfin/jellyfin/pull/3515.
+ if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ return true;
+ }
+ else
+ {
+ return address.IsPrivateAddressRange();
+ }
+ }
+
+ /// <inheritdoc/>
+ public bool IsExcludedInterface(IPAddress address)
+ {
+ return _bindExclusions.ContainsAddress(address);
+ }
+
+ /// <inheritdoc/>
+ public Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null)
+ {
+ if (filter == null)
+ {
+ return _lanSubnets.Exclude(_excludedSubnets).AsNetworks();
+ }
+
+ return _lanSubnets.Exclude(filter);
+ }
+
+ /// <inheritdoc/>
+ public bool IsValidInterfaceAddress(IPAddress address)
+ {
+ return _interfaceAddresses.ContainsAddress(address);
+ }
+
+ /// <inheritdoc/>
+ public bool TryParseInterface(string token, out Collection<IPObject>? result)
+ {
+ result = null;
+ if (string.IsNullOrEmpty(token))
+ {
+ return false;
+ }
+
+ if (_interfaceNames != null && _interfaceNames.TryGetValue(token.ToLower(CultureInfo.InvariantCulture), out int index))
+ {
+ result = new Collection<IPObject>();
+
+ _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token);
+
+ // Replace interface tags with the interface IP's.
+ foreach (IPNetAddress iface in _interfaceAddresses)
+ {
+ if (Math.Abs(iface.Tag) == index
+ && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork)
+ || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6)))
+ {
+ result.AddItem(iface);
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Reloads all settings and re-initialises the instance.
+ /// </summary>
+ /// <param name="configuration">The <see cref="NetworkConfiguration"/> to use.</param>
+ public void UpdateSettings(object configuration)
+ {
+ NetworkConfiguration config = (NetworkConfiguration)configuration ?? throw new ArgumentNullException(nameof(configuration));
+
+ IsIP4Enabled = Socket.OSSupportsIPv4 && config.EnableIPV4;
+ IsIP6Enabled = Socket.OSSupportsIPv6 && config.EnableIPV6;
+
+ if (!IsIP6Enabled && !IsIP4Enabled)
+ {
+ _logger.LogError("IPv4 and IPv6 cannot both be disabled.");
+ IsIP4Enabled = true;
+ }
+
+ TrustAllIP6Interfaces = config.TrustAllIP6Interfaces;
+ // UdpHelper.EnableMultiSocketBinding = config.EnableMultiSocketBinding;
+
+ if (string.IsNullOrEmpty(MockNetworkSettings))
+ {
+ InitialiseInterfaces();
+ }
+ else // Used in testing only.
+ {
+ // Format is <IPAddress>,<Index>,<Name>: <next interface>. Set index to -ve to simulate a gateway.
+ var interfaceList = MockNetworkSettings.Split(':');
+ foreach (var details in interfaceList)
+ {
+ var parts = details.Split(',');
+ var address = IPNetAddress.Parse(parts[0]);
+ var index = int.Parse(parts[1], CultureInfo.InvariantCulture);
+ address.Tag = index;
+ _interfaceAddresses.AddItem(address);
+ _interfaceNames.Add(parts[2], Math.Abs(index));
+ }
+ }
+
+ InitialiseLAN(config);
+ InitialiseBind(config);
+ InitialiseRemote(config);
+ InitialiseOverrides(config);
+ }
+
+ /// <summary>
+ /// Protected implementation of Dispose pattern.
+ /// </summary>
+ /// <param name="disposing"><c>True</c> to dispose the managed state.</param>
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposed)
+ {
+ if (disposing)
+ {
+ _configurationManager.NamedConfigurationUpdated -= ConfigurationUpdated;
+ NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged;
+ NetworkChange.NetworkAvailabilityChanged -= OnNetworkAvailabilityChanged;
+ }
+
+ _disposed = true;
+ }
+ }
+
+ /// <summary>
+ /// Tries to identify the string and return an object of that class.
+ /// </summary>
+ /// <param name="addr">String to parse.</param>
+ /// <param name="result">IPObject to return.</param>
+ /// <returns><c>true</c> if the value parsed successfully, <c>false</c> otherwise.</returns>
+ private static bool TryParse(string addr, out IPObject result)
+ {
+ if (!string.IsNullOrEmpty(addr))
+ {
+ // Is it an IP address
+ if (IPNetAddress.TryParse(addr, out IPNetAddress nw))
+ {
+ result = nw;
+ return true;
+ }
+
+ if (IPHost.TryParse(addr, out IPHost h))
+ {
+ result = h;
+ return true;
+ }
+ }
+
+ result = IPNetAddress.None;
+ return false;
+ }
+
+ /// <summary>
+ /// Converts an IPAddress into a string.
+ /// Ipv6 addresses are returned in [ ], with their scope removed.
+ /// </summary>
+ /// <param name="address">Address to convert.</param>
+ /// <returns>URI safe conversion of the address.</returns>
+ private static string FormatIP6String(IPAddress address)
+ {
+ var str = address.ToString();
+ if (address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ int i = str.IndexOf("%", StringComparison.OrdinalIgnoreCase);
+ if (i != -1)
+ {
+ str = str.Substring(0, i);
+ }
+
+ return $"[{str}]";
+ }
+
+ return str;
+ }
+
+ private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt)
+ {
+ if (evt.Key.Equals("network", StringComparison.Ordinal))
+ {
+ UpdateSettings((NetworkConfiguration)evt.NewConfiguration);
+ }
+ }
+
+ /// <summary>
+ /// Checks the string to see if it matches any interface names.
+ /// </summary>
+ /// <param name="token">String to check.</param>
+ /// <param name="index">Interface index number.</param>
+ /// <returns><c>true</c> if an interface name matches the token, <c>False</c> otherwise.</returns>
+ private bool IsInterface(string token, out int index)
+ {
+ index = -1;
+
+ // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1.
+ // Null check required here for automated testing.
+ if (_interfaceNames != null && token.Length > 1)
+ {
+ bool partial = token[^1] == '*';
+ if (partial)
+ {
+ token = token[0..^1];
+ }
+
+ foreach ((string interfc, int interfcIndex) in _interfaceNames)
+ {
+ if ((!partial && string.Equals(interfc, token, StringComparison.OrdinalIgnoreCase))
+ || (partial && interfc.StartsWith(token, true, CultureInfo.InvariantCulture)))
+ {
+ index = interfcIndex;
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Parses a string and adds it into the collection, replacing any interface references.
+ /// </summary>
+ /// <param name="col"><see cref="Collection{IPObject}"/>Collection.</param>
+ /// <param name="token">String value to parse.</param>
+ private void AddToCollection(Collection<IPObject> col, string token)
+ {
+ // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1.
+ // Null check required here for automated testing.
+ if (IsInterface(token, out int index))
+ {
+ _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token);
+
+ // Replace interface tags with the interface IP's.
+ foreach (IPNetAddress iface in _interfaceAddresses)
+ {
+ if (Math.Abs(iface.Tag) == index
+ && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork)
+ || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6)))
+ {
+ col.AddItem(iface);
+ }
+ }
+ }
+ else if (TryParse(token, out IPObject obj))
+ {
+ // Expand if the ip address is "any".
+ if ((obj.Address.Equals(IPAddress.Any) && IsIP4Enabled)
+ || (obj.Address.Equals(IPAddress.IPv6Any) && IsIP6Enabled))
+ {
+ foreach (IPNetAddress iface in _interfaceAddresses)
+ {
+ if (obj.AddressFamily == iface.AddressFamily)
+ {
+ col.AddItem(iface);
+ }
+ }
+ }
+ else if (!IsIP6Enabled)
+ {
+ // Remove IP6 addresses from multi-homed IPHosts.
+ obj.Remove(AddressFamily.InterNetworkV6);
+ if (!obj.IsIP6())
+ {
+ col.AddItem(obj);
+ }
+ }
+ else if (!IsIP4Enabled)
+ {
+ // Remove IP4 addresses from multi-homed IPHosts.
+ obj.Remove(AddressFamily.InterNetwork);
+ if (obj.IsIP6())
+ {
+ col.AddItem(obj);
+ }
+ }
+ else
+ {
+ col.AddItem(obj);
+ }
+ }
+ else
+ {
+ _logger.LogDebug("Invalid or unknown network {Token}.", token);
+ }
+ }
+
+ /// <summary>
+ /// Handler for network change events.
+ /// </summary>
+ /// <param name="sender">Sender.</param>
+ /// <param name="e">A <see cref="NetworkAvailabilityEventArgs"/> containing network availability information.</param>
+ private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e)
+ {
+ _logger.LogDebug("Network availability changed.");
+ OnNetworkChanged();
+ }
+
+ /// <summary>
+ /// Handler for network change events.
+ /// </summary>
+ /// <param name="sender">Sender.</param>
+ /// <param name="e">An <see cref="EventArgs"/>.</param>
+ private void OnNetworkAddressChanged(object? sender, EventArgs e)
+ {
+ _logger.LogDebug("Network address change detected.");
+ OnNetworkChanged();
+ }
+
+ /// <summary>
+ /// Async task that waits for 2 seconds before re-initialising the settings, as typically these events fire multiple times in succession.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ private async Task OnNetworkChangeAsync()
+ {
+ try
+ {
+ await Task.Delay(2000).ConfigureAwait(false);
+ InitialiseInterfaces();
+ // Recalculate LAN caches.
+ InitialiseLAN(_configurationManager.GetNetworkConfiguration());
+
+ NetworkChanged?.Invoke(this, EventArgs.Empty);
+ }
+ finally
+ {
+ _eventfire = false;
+ }
+ }
+
+ /// <summary>
+ /// Triggers our event, and re-loads interface information.
+ /// </summary>
+ private void OnNetworkChanged()
+ {
+ lock (_eventFireLock)
+ {
+ if (!_eventfire)
+ {
+ _logger.LogDebug("Network Address Change Event.");
+ // As network events tend to fire one after the other only fire once every second.
+ _eventfire = true;
+ OnNetworkChangeAsync().GetAwaiter().GetResult();
+ }
+ }
+ }
+
+ /// <summary>
+ /// Parses the user defined overrides into the dictionary object.
+ /// Overrides are the equivalent of localised publishedServerUrl, enabling
+ /// different addresses to be advertised over different subnets.
+ /// format is subnet=ipaddress|host|uri
+ /// when subnet = 0.0.0.0, any external address matches.
+ /// </summary>
+ private void InitialiseOverrides(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ _publishedServerUrls.Clear();
+ string[] overrides = config.PublishedServerUriBySubnet;
+ if (overrides == null)
+ {
+ return;
+ }
+
+ foreach (var entry in overrides)
+ {
+ var parts = entry.Split('=');
+ if (parts.Length != 2)
+ {
+ _logger.LogError("Unable to parse bind override: {Entry}", entry);
+ }
+ else
+ {
+ var replacement = parts[1].Trim();
+ if (string.Equals(parts[0], "all", StringComparison.OrdinalIgnoreCase))
+ {
+ _publishedServerUrls[new IPNetAddress(IPAddress.Broadcast)] = replacement;
+ }
+ else if (string.Equals(parts[0], "external", StringComparison.OrdinalIgnoreCase))
+ {
+ _publishedServerUrls[new IPNetAddress(IPAddress.Any)] = replacement;
+ }
+ else if (TryParseInterface(parts[0], out Collection<IPObject>? addresses) && addresses != null)
+ {
+ foreach (IPNetAddress na in addresses)
+ {
+ _publishedServerUrls[na] = replacement;
+ }
+ }
+ else if (IPNetAddress.TryParse(parts[0], out IPNetAddress result))
+ {
+ _publishedServerUrls[result] = replacement;
+ }
+ else
+ {
+ _logger.LogError("Unable to parse bind ip address. {Parts}", parts[1]);
+ }
+ }
+ }
+ }
+ }
+
+ /// <summary>
+ /// Initialises the network bind addresses.
+ /// </summary>
+ private void InitialiseBind(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ string[] lanAddresses = config.LocalNetworkAddresses;
+
+ // TODO: remove when bug fixed: https://github.com/jellyfin/jellyfin-web/issues/1334
+
+ if (lanAddresses.Length == 1 && lanAddresses[0].IndexOf(',', StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ lanAddresses = lanAddresses[0].Split(',');
+ }
+
+ // TODO: end fix: https://github.com/jellyfin/jellyfin-web/issues/1334
+
+ // Add virtual machine interface names to the list of bind exclusions, so that they are auto-excluded.
+ if (config.IgnoreVirtualInterfaces)
+ {
+ var virtualInterfaceNames = config.VirtualInterfaceNames.Split(',');
+ var newList = new string[lanAddresses.Length + virtualInterfaceNames.Length];
+ Array.Copy(lanAddresses, newList, lanAddresses.Length);
+ Array.Copy(virtualInterfaceNames, 0, newList, lanAddresses.Length, virtualInterfaceNames.Length);
+ lanAddresses = newList;
+ }
+
+ // Read and parse bind addresses and exclusions, removing ones that don't exist.
+ _bindAddresses = CreateIPCollection(lanAddresses).Union(_interfaceAddresses);
+ _bindExclusions = CreateIPCollection(lanAddresses, true).Union(_interfaceAddresses);
+ _logger.LogInformation("Using bind addresses: {0}", _bindAddresses.AsString());
+ _logger.LogInformation("Using bind exclusions: {0}", _bindExclusions.AsString());
+ }
+ }
+
+ /// <summary>
+ /// Initialises the remote address values.
+ /// </summary>
+ private void InitialiseRemote(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ RemoteAddressFilter = CreateIPCollection(config.RemoteIPFilter);
+ }
+ }
+
+ /// <summary>
+ /// Initialises internal LAN cache settings.
+ /// </summary>
+ private void InitialiseLAN(NetworkConfiguration config)
+ {
+ lock (_intLock)
+ {
+ _logger.LogDebug("Refreshing LAN information.");
+
+ // Get configuration options.
+ string[] subnets = config.LocalNetworkSubnets;
+
+ // Create lists from user settings.
+
+ _lanSubnets = CreateIPCollection(subnets);
+ _excludedSubnets = CreateIPCollection(subnets, true).AsNetworks();
+
+ // If no LAN addresses are specified - all private subnets are deemed to be the LAN
+ _usingPrivateAddresses = _lanSubnets.Count == 0;
+
+ // NOTE: The order of the commands generating the collection in this statement matters.
+ // Altering the order will cause the collections to be created incorrectly.
+ if (_usingPrivateAddresses)
+ {
+ _logger.LogDebug("Using LAN interface addresses as user provided no LAN details.");
+ // Internal interfaces must be private and not excluded.
+ _internalInterfaces = CreateCollection(_interfaceAddresses.Where(i => IsPrivateAddressRange(i) && !_excludedSubnets.ContainsAddress(i)));
+
+ // Subnets are the same as the calculated internal interface.
+ _lanSubnets = new Collection<IPObject>();
+
+ // We must listen on loopback for LiveTV to function regardless of the settings.
+ if (IsIP6Enabled)
+ {
+ _lanSubnets.AddItem(IPNetAddress.IP6Loopback);
+ _lanSubnets.AddItem(IPNetAddress.Parse("fc00::/7")); // ULA
+ _lanSubnets.AddItem(IPNetAddress.Parse("fe80::/10")); // Site local
+ }
+
+ if (IsIP4Enabled)
+ {
+ _lanSubnets.AddItem(IPNetAddress.IP4Loopback);
+ _lanSubnets.AddItem(IPNetAddress.Parse("10.0.0.0/8"));
+ _lanSubnets.AddItem(IPNetAddress.Parse("172.16.0.0/12"));
+ _lanSubnets.AddItem(IPNetAddress.Parse("192.168.0.0/16"));
+ }
+ }
+ else
+ {
+ // We must listen on loopback for LiveTV to function regardless of the settings.
+ if (IsIP6Enabled)
+ {
+ _lanSubnets.AddItem(IPNetAddress.IP6Loopback);
+ }
+
+ if (IsIP4Enabled)
+ {
+ _lanSubnets.AddItem(IPNetAddress.IP4Loopback);
+ }
+
+ // Internal interfaces must be private, not excluded and part of the LocalNetworkSubnet.
+ _internalInterfaces = CreateCollection(_interfaceAddresses.Where(i => IsInLocalNetwork(i)));
+ }
+
+ _logger.LogInformation("Defined LAN addresses : {0}", _lanSubnets.AsString());
+ _logger.LogInformation("Defined LAN exclusions : {0}", _excludedSubnets.AsString());
+ _logger.LogInformation("Using LAN addresses: {0}", _lanSubnets.Exclude(_excludedSubnets).AsNetworks().AsString());
+ }
+ }
+
+ /// <summary>
+ /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state.
+ /// Generate a list of all active mac addresses that aren't loopback addresses.
+ /// </summary>
+ private void InitialiseInterfaces()
+ {
+ lock (_intLock)
+ {
+ _logger.LogDebug("Refreshing interfaces.");
+
+ _interfaceNames.Clear();
+ _interfaceAddresses.Clear();
+ _macAddresses.Clear();
+
+ try
+ {
+ IEnumerable<NetworkInterface> nics = NetworkInterface.GetAllNetworkInterfaces()
+ .Where(i => i.SupportsMulticast && i.OperationalStatus == OperationalStatus.Up);
+
+ foreach (NetworkInterface adapter in nics)
+ {
+ try
+ {
+ IPInterfaceProperties ipProperties = adapter.GetIPProperties();
+ PhysicalAddress mac = adapter.GetPhysicalAddress();
+
+ // populate mac list
+ if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && mac != null && mac != PhysicalAddress.None)
+ {
+ _macAddresses.Add(mac);
+ }
+
+ // populate interface address list
+ foreach (UnicastIPAddressInformation info in ipProperties.UnicastAddresses)
+ {
+ if (IsIP4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ IPNetAddress nw = new IPNetAddress(info.Address, IPObject.MaskToCidr(info.IPv4Mask))
+ {
+ // Keep the number of gateways on this interface, along with its index.
+ Tag = ipProperties.GetIPv4Properties().Index
+ };
+
+ int tag = nw.Tag;
+ if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback())
+ {
+ // -ve Tags signify the interface has a gateway.
+ nw.Tag *= -1;
+ }
+
+ _interfaceAddresses.AddItem(nw);
+
+ // Store interface name so we can use the name in Collections.
+ _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag;
+ _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag;
+ }
+ else if (IsIP6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6)
+ {
+ IPNetAddress nw = new IPNetAddress(info.Address, (byte)info.PrefixLength)
+ {
+ // Keep the number of gateways on this interface, along with its index.
+ Tag = ipProperties.GetIPv6Properties().Index
+ };
+
+ int tag = nw.Tag;
+ if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback())
+ {
+ // -ve Tags signify the interface has a gateway.
+ nw.Tag *= -1;
+ }
+
+ _interfaceAddresses.AddItem(nw);
+
+ // Store interface name so we can use the name in Collections.
+ _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag;
+ _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag;
+ }
+ }
+ }
+#pragma warning disable CA1031 // Do not catch general exception types
+ catch (Exception ex)
+ {
+ // Ignore error, and attempt to continue.
+ _logger.LogError(ex, "Error encountered parsing interfaces.");
+ }
+#pragma warning restore CA1031 // Do not catch general exception types
+ }
+
+ _logger.LogDebug("Discovered {0} interfaces.", _interfaceAddresses.Count);
+ _logger.LogDebug("Interfaces addresses : {0}", _interfaceAddresses.AsString());
+
+ // If for some reason we don't have an interface info, resolve our DNS name.
+ if (_interfaceAddresses.Count == 0)
+ {
+ _logger.LogError("No interfaces information available. Resolving DNS name.");
+ IPHost host = new IPHost(Dns.GetHostName());
+ foreach (var a in host.GetAddresses())
+ {
+ _interfaceAddresses.AddItem(a);
+ }
+
+ if (_interfaceAddresses.Count == 0)
+ {
+ _logger.LogWarning("No interfaces information available. Using loopback.");
+ // Last ditch attempt - use loopback address.
+ _interfaceAddresses.AddItem(IPNetAddress.IP4Loopback);
+ if (IsIP6Enabled)
+ {
+ _interfaceAddresses.AddItem(IPNetAddress.IP6Loopback);
+ }
+ }
+ }
+ }
+ catch (NetworkInformationException ex)
+ {
+ _logger.LogError(ex, "Error in InitialiseInterfaces.");
+ }
+ }
+ }
+
+ /// <summary>
+ /// Attempts to match the source against a user defined bind interface.
+ /// </summary>
+ /// <param name="source">IP source address to use.</param>
+ /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param>
+ /// <param name="bindPreference">The published server url that matches the source address.</param>
+ /// <param name="port">The resultant port, if one exists.</param>
+ /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
+ private bool MatchesPublishedServerUrl(IPObject source, bool isInExternalSubnet, out string bindPreference, out int? port)
+ {
+ bindPreference = string.Empty;
+ port = null;
+
+ // Check for user override.
+ foreach (var addr in _publishedServerUrls)
+ {
+ // Remaining. Match anything.
+ if (addr.Key.Address.Equals(IPAddress.Broadcast))
+ {
+ bindPreference = addr.Value;
+ break;
+ }
+ else if ((addr.Key.Address.Equals(IPAddress.Any) || addr.Key.Address.Equals(IPAddress.IPv6Any)) && isInExternalSubnet)
+ {
+ // External.
+ bindPreference = addr.Value;
+ break;
+ }
+ else if (addr.Key.Contains(source))
+ {
+ // Match ip address.
+ bindPreference = addr.Value;
+ break;
+ }
+ }
+
+ if (string.IsNullOrEmpty(bindPreference))
+ {
+ return false;
+ }
+
+ // Has it got a port defined?
+ var parts = bindPreference.Split(':');
+ if (parts.Length > 1)
+ {
+ if (int.TryParse(parts[1], out int p))
+ {
+ bindPreference = parts[0];
+ port = p;
+ }
+ }
+
+ return true;
+ }
+
+ /// <summary>
+ /// Attempts to match the source against a user defined bind interface.
+ /// </summary>
+ /// <param name="source">IP source address to use.</param>
+ /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param>
+ /// <param name="result">The result, if a match is found.</param>
+ /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
+ private bool MatchesBindInterface(IPObject source, bool isInExternalSubnet, out string result)
+ {
+ result = string.Empty;
+ var addresses = _bindAddresses.Exclude(_bindExclusions);
+
+ int count = addresses.Count;
+ if (count == 1 && (_bindAddresses[0].Equals(IPAddress.Any) || _bindAddresses[0].Equals(IPAddress.IPv6Any)))
+ {
+ // Ignore IPAny addresses.
+ count = 0;
+ }
+
+ if (count != 0)
+ {
+ // Check to see if any of the bind interfaces are in the same subnet.
+
+ IPAddress? defaultGateway = null;
+ IPAddress? bindAddress = null;
+
+ if (isInExternalSubnet)
+ {
+ // Find all external bind addresses. Store the default gateway, but check to see if there is a better match first.
+ foreach (var addr in addresses.OrderBy(p => p.Tag))
+ {
+ if (defaultGateway == null && !IsInLocalNetwork(addr))
+ {
+ defaultGateway = addr.Address;
+ }
+
+ if (bindAddress == null && addr.Contains(source))
+ {
+ bindAddress = addr.Address;
+ }
+
+ if (defaultGateway != null && bindAddress != null)
+ {
+ break;
+ }
+ }
+ }
+ else
+ {
+ // Look for the best internal address.
+ bindAddress = addresses
+ .Where(p => IsInLocalNetwork(p) && (p.Contains(source) || p.Equals(IPAddress.None)))
+ .OrderBy(p => p.Tag)
+ .FirstOrDefault()?.Address;
+ }
+
+ if (bindAddress != null)
+ {
+ result = FormatIP6String(bindAddress);
+ _logger.LogDebug("{Source}: GetBindInterface: Has source, found a match bind interface subnets. {Result}", source, result);
+ return true;
+ }
+
+ if (isInExternalSubnet && defaultGateway != null)
+ {
+ result = FormatIP6String(defaultGateway);
+ _logger.LogDebug("{Source}: GetBindInterface: Using first user defined external interface. {Result}", source, result);
+ return true;
+ }
+
+ result = FormatIP6String(addresses[0].Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Selected first user defined interface. {Result}", source, result);
+
+ if (isInExternalSubnet)
+ {
+ _logger.LogWarning("{Source}: External request received, however, only an internal interface bind found.", source);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Attempts to match the source against an external interface.
+ /// </summary>
+ /// <param name="source">IP source address to use.</param>
+ /// <param name="result">The result, if a match is found.</param>
+ /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
+ private bool MatchesExternalInterface(IPObject source, out string result)
+ {
+ result = string.Empty;
+ // Get the first WAN interface address that isn't a loopback.
+ var extResult = _interfaceAddresses
+ .Exclude(_bindExclusions)
+ .Where(p => !IsInLocalNetwork(p))
+ .OrderBy(p => p.Tag);
+
+ if (extResult.Any())
+ {
+ // Does the request originate in one of the interface subnets?
+ // (For systems with multiple internal network cards, and multiple subnets)
+ foreach (var intf in extResult)
+ {
+ if (!IsInLocalNetwork(intf) && intf.Contains(source))
+ {
+ result = FormatIP6String(intf.Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Selected best external on interface on range. {Result}", source, result);
+ return true;
+ }
+ }
+
+ result = FormatIP6String(extResult.First().Address);
+ _logger.LogDebug("{Source}: GetBindInterface: Selected first external interface. {Result}", source, result);
+ return true;
+ }
+
+ // Have to return something, so return an internal address
+
+ _logger.LogWarning("{Source}: External request received, however, no WAN interface found.", source);
+ return false;
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
index 51a882c14..a0bad29e9 100644
--- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
+++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs
@@ -59,6 +59,12 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
var user = eventArgs.Users[0];
+ var notificationType = GetPlaybackStoppedNotificationType(item.MediaType);
+ if (notificationType == null)
+ {
+ return;
+ }
+
await _activityManager.CreateAsync(new ActivityLog(
string.Format(
CultureInfo.InvariantCulture,
@@ -66,7 +72,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session
user.Username,
GetItemName(item),
eventArgs.DeviceName),
- GetPlaybackStoppedNotificationType(item.MediaType),
+ notificationType,
user.Id))
.ConfigureAwait(false);
}
diff --git a/Jellyfin.Server.Implementations/JellyfinDb.cs b/Jellyfin.Server.Implementations/JellyfinDb.cs
index 45e71f16e..7f3f83749 100644
--- a/Jellyfin.Server.Implementations/JellyfinDb.cs
+++ b/Jellyfin.Server.Implementations/JellyfinDb.cs
@@ -1,5 +1,6 @@
#pragma warning disable CS1591
+using System;
using System.Linq;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Interfaces;
@@ -33,6 +34,8 @@ namespace Jellyfin.Server.Implementations
public virtual DbSet<ItemDisplayPreferences> ItemDisplayPreferences { get; set; }
+ public virtual DbSet<CustomItemDisplayPreferences> CustomItemDisplayPreferences { get; set; }
+
public virtual DbSet<Permission> Permissions { get; set; }
public virtual DbSet<Preference> Preferences { get; set; }
@@ -140,6 +143,7 @@ namespace Jellyfin.Server.Implementations
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
+ modelBuilder.SetDefaultDateTimeKind(DateTimeKind.Utc);
base.OnModelCreating(modelBuilder);
modelBuilder.HasDefaultSchema("jellyfin");
@@ -149,7 +153,15 @@ namespace Jellyfin.Server.Implementations
.IsUnique(false);
modelBuilder.Entity<DisplayPreferences>()
- .HasIndex(entity => new { entity.UserId, entity.Client })
+ .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client })
+ .IsUnique();
+
+ modelBuilder.Entity<CustomItemDisplayPreferences>()
+ .HasIndex(entity => entity.UserId)
+ .IsUnique(false);
+
+ modelBuilder.Entity<CustomItemDisplayPreferences>()
+ .HasIndex(entity => new { entity.UserId, entity.ItemId, entity.Client, entity.Key })
.IsUnique();
}
}
diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs
new file mode 100644
index 000000000..10663d065
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.Designer.cs
@@ -0,0 +1,522 @@
+#pragma warning disable CS1591
+// <auto-generated />
+using System;
+using Jellyfin.Server.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ [DbContext(typeof(JellyfinDb))]
+ [Migration("20201204223655_AddCustomDisplayPreferences")]
+ partial class AddCustomDisplayPreferences
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("jellyfin")
+ .HasAnnotation("ProductVersion", "5.0.0");
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("ActivityLogs");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Permission_Permissions_Guid");
+
+ b.ToTable("Permissions");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Preference_Preferences_Guid");
+
+ b.ToTable("Preferences");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("EasyPassword")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalAgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithOne("DisplayPreferences")
+ .HasForeignKey("Jellyfin.Data.Entities.DisplayPreferences", "UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Data.Entities.ImageInfo", "UserId");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("Permission_Permissions_Guid");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Data.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("Preference_Preferences_Guid");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences")
+ .IsRequired();
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs
new file mode 100644
index 000000000..fbc0bffa9
--- /dev/null
+++ b/Jellyfin.Server.Implementations/Migrations/20201204223655_AddCustomDisplayPreferences.cs
@@ -0,0 +1,108 @@
+#pragma warning disable CS1591
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace Jellyfin.Server.Implementations.Migrations
+{
+ public partial class AddCustomDisplayPreferences : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_DisplayPreferences_UserId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences");
+
+ migrationBuilder.AlterColumn<int>(
+ name: "MaxActiveSessions",
+ schema: "jellyfin",
+ table: "Users",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: 0,
+ oldClrType: typeof(int),
+ oldType: "INTEGER",
+ oldNullable: true);
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "ItemId",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ type: "TEXT",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.CreateTable(
+ name: "CustomItemDisplayPreferences",
+ schema: "jellyfin",
+ columns: table => new
+ {
+ Id = table.Column<int>(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ UserId = table.Column<Guid>(type: "TEXT", nullable: false),
+ ItemId = table.Column<Guid>(type: "TEXT", nullable: false),
+ Client = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
+ Key = table.Column<string>(type: "TEXT", nullable: false),
+ Value = table.Column<string>(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_CustomItemDisplayPreferences", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DisplayPreferences_UserId_ItemId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ columns: new[] { "UserId", "ItemId", "Client" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_CustomItemDisplayPreferences_UserId",
+ schema: "jellyfin",
+ table: "CustomItemDisplayPreferences",
+ column: "UserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_CustomItemDisplayPreferences_UserId_ItemId_Client_Key",
+ schema: "jellyfin",
+ table: "CustomItemDisplayPreferences",
+ columns: new[] { "UserId", "ItemId", "Client", "Key" },
+ unique: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "CustomItemDisplayPreferences",
+ schema: "jellyfin");
+
+ migrationBuilder.DropIndex(
+ name: "IX_DisplayPreferences_UserId_ItemId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences");
+
+ migrationBuilder.DropColumn(
+ name: "ItemId",
+ schema: "jellyfin",
+ table: "DisplayPreferences");
+
+ migrationBuilder.AlterColumn<int>(
+ name: "MaxActiveSessions",
+ schema: "jellyfin",
+ table: "Users",
+ type: "INTEGER",
+ nullable: true,
+ oldClrType: typeof(int),
+ oldType: "INTEGER");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DisplayPreferences_UserId_Client",
+ schema: "jellyfin",
+ table: "DisplayPreferences",
+ columns: new[] { "UserId", "Client" },
+ unique: true);
+ }
+ }
+}
diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
index 16d62f482..1614a88ef 100644
--- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
+++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("jellyfin")
- .HasAnnotation("ProductVersion", "3.1.8");
+ .HasAnnotation("ProductVersion", "5.0.0");
modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b =>
{
@@ -52,33 +52,33 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("TEXT");
b.Property<string>("ItemId")
- .HasColumnType("TEXT")
- .HasMaxLength(256);
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
b.Property<int>("LogSeverity")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(512);
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
b.Property<string>("Overview")
- .HasColumnType("TEXT")
- .HasMaxLength(512);
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("ShortOverview")
- .HasColumnType("TEXT")
- .HasMaxLength(512);
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(256);
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
@@ -88,6 +88,41 @@ namespace Jellyfin.Server.Implementations.Migrations
b.ToTable("ActivityLogs");
});
+ modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+ });
+
modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
{
b.Property<int>("Id")
@@ -99,12 +134,12 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Client")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(32);
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
b.Property<string>("DashboardTheme")
- .HasColumnType("TEXT")
- .HasMaxLength(32);
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
b.Property<bool>("EnableNextVideoInfoOverlay")
.HasColumnType("INTEGER");
@@ -112,6 +147,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<int?>("IndexBy")
.HasColumnType("INTEGER");
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
b.Property<int>("ScrollDirection")
.HasColumnType("INTEGER");
@@ -128,8 +166,8 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("TvHome")
- .HasColumnType("TEXT")
- .HasMaxLength(32);
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
@@ -138,7 +176,7 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasIndex("UserId");
- b.HasIndex("UserId", "Client")
+ b.HasIndex("UserId", "ItemId", "Client")
.IsUnique();
b.ToTable("DisplayPreferences");
@@ -177,8 +215,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Path")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(512);
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
b.Property<Guid?>("UserId")
.HasColumnType("TEXT");
@@ -199,8 +237,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Client")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(32);
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
b.Property<int?>("IndexBy")
.HasColumnType("INTEGER");
@@ -216,8 +254,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("SortBy")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(64);
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
@@ -279,8 +317,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Value")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(65535);
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
b.HasKey("Id");
@@ -296,13 +334,13 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("TEXT");
b.Property<string>("AudioLanguagePreference")
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.Property<string>("AuthenticationProviderId")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.Property<bool>("DisplayCollectionsView")
.HasColumnType("INTEGER");
@@ -311,8 +349,8 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("EasyPassword")
- .HasColumnType("TEXT")
- .HasMaxLength(65535);
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
b.Property<bool>("EnableAutoLogin")
.HasColumnType("INTEGER");
@@ -354,13 +392,13 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("Password")
- .HasColumnType("TEXT")
- .HasMaxLength(65535);
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
b.Property<string>("PasswordResetProviderId")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.Property<bool>("PlayDefaultAudioTrack")
.HasColumnType("INTEGER");
@@ -379,8 +417,8 @@ namespace Jellyfin.Server.Implementations.Migrations
.HasColumnType("INTEGER");
b.Property<string>("SubtitleLanguagePreference")
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.Property<int>("SubtitleMode")
.HasColumnType("INTEGER");
@@ -390,8 +428,8 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("Username")
.IsRequired()
- .HasColumnType("TEXT")
- .HasMaxLength(255);
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
b.HasKey("Id");
@@ -454,6 +492,27 @@ namespace Jellyfin.Server.Implementations.Migrations
.WithMany("Preferences")
.HasForeignKey("Preference_Preferences_Guid");
});
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Data.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences")
+ .IsRequired();
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
#pragma warning restore 612, 618
}
}
diff --git a/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs b/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs
new file mode 100644
index 000000000..80ad65a42
--- /dev/null
+++ b/Jellyfin.Server.Implementations/ModelBuilderExtensions.cs
@@ -0,0 +1,48 @@
+using System;
+using Jellyfin.Server.Implementations.ValueConverters;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace Jellyfin.Server.Implementations
+{
+ /// <summary>
+ /// Model builder extensions.
+ /// </summary>
+ public static class ModelBuilderExtensions
+ {
+ /// <summary>
+ /// Specify value converter for the object type.
+ /// </summary>
+ /// <param name="modelBuilder">The model builder.</param>
+ /// <param name="converter">The <see cref="ValueConverter{TModel,TProvider}"/>.</param>
+ /// <typeparam name="T">The type to convert.</typeparam>
+ /// <returns>The modified <see cref="ModelBuilder"/>.</returns>
+ public static ModelBuilder UseValueConverterForType<T>(this ModelBuilder modelBuilder, ValueConverter converter)
+ {
+ var type = typeof(T);
+ foreach (var entityType in modelBuilder.Model.GetEntityTypes())
+ {
+ foreach (var property in entityType.GetProperties())
+ {
+ if (property.ClrType == type)
+ {
+ property.SetValueConverter(converter);
+ }
+ }
+ }
+
+ return modelBuilder;
+ }
+
+ /// <summary>
+ /// Specify the default <see cref="DateTimeKind"/>.
+ /// </summary>
+ /// <param name="modelBuilder">The model builder to extend.</param>
+ /// <param name="kind">The <see cref="DateTimeKind"/> to specify.</param>
+ public static void SetDefaultDateTimeKind(this ModelBuilder modelBuilder, DateTimeKind kind)
+ {
+ modelBuilder.UseValueConverterForType<DateTime>(new DateTimeKindValueConverter(kind));
+ modelBuilder.UseValueConverterForType<DateTime?>(new DateTimeKindValueConverter(kind));
+ }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
index f79e433a6..662b4bf65 100644
--- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs
@@ -53,7 +53,7 @@ namespace Jellyfin.Server.Implementations.Users
bool success = false;
- // As long as jellyfin supports passwordless users, we need this little block here to accommodate
+ // As long as jellyfin supports password-less users, we need this little block here to accommodate
if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password))
{
return Task.FromResult(new ProviderAuthenticationResult
diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
index 76f943385..c8a589cab 100644
--- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
+++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs
@@ -26,16 +26,16 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
- public DisplayPreferences GetDisplayPreferences(Guid userId, string client)
+ public DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client)
{
var prefs = _dbContext.DisplayPreferences
.Include(pref => pref.HomeSections)
.FirstOrDefault(pref =>
- pref.UserId == userId && string.Equals(pref.Client, client));
+ pref.UserId == userId && string.Equals(pref.Client, client) && pref.ItemId == itemId);
if (prefs == null)
{
- prefs = new DisplayPreferences(userId, client);
+ prefs = new DisplayPreferences(userId, itemId, client);
_dbContext.DisplayPreferences.Add(prefs);
}
@@ -67,6 +67,34 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
+ public IDictionary<string, string> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client)
+ {
+ return _dbContext.CustomItemDisplayPreferences
+ .AsQueryable()
+ .Where(prefs => prefs.UserId == userId
+ && prefs.ItemId == itemId
+ && string.Equals(prefs.Client, client))
+ .ToDictionary(prefs => prefs.Key, prefs => prefs.Value);
+ }
+
+ /// <inheritdoc />
+ public void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string> customPreferences)
+ {
+ var existingPrefs = _dbContext.CustomItemDisplayPreferences
+ .AsQueryable()
+ .Where(prefs => prefs.UserId == userId
+ && prefs.ItemId == itemId
+ && string.Equals(prefs.Client, client));
+ _dbContext.CustomItemDisplayPreferences.RemoveRange(existingPrefs);
+
+ foreach (var (key, value) in customPreferences)
+ {
+ _dbContext.CustomItemDisplayPreferences
+ .Add(new CustomItemDisplayPreferences(userId, itemId, client, key, value));
+ }
+ }
+
+ /// <inheritdoc />
public void SaveChanges()
{
_dbContext.SaveChanges();
diff --git a/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs b/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs
new file mode 100644
index 000000000..8a510898b
--- /dev/null
+++ b/Jellyfin.Server.Implementations/ValueConverters/DateTimeKindValueConverter.cs
@@ -0,0 +1,21 @@
+using System;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace Jellyfin.Server.Implementations.ValueConverters
+{
+ /// <summary>
+ /// ValueConverter to specify kind.
+ /// </summary>
+ public class DateTimeKindValueConverter : ValueConverter<DateTime, DateTime>
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="DateTimeKindValueConverter"/> class.
+ /// </summary>
+ /// <param name="kind">The kind to specify.</param>
+ /// <param name="mappingHints">The mapping hints.</param>
+ public DateTimeKindValueConverter(DateTimeKind kind, ConverterMappingHints mappingHints = null)
+ : base(v => v.ToUniversalTime(), v => DateTime.SpecifyKind(v, kind), mappingHints)
+ {
+ }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs
index c44736447..b76aa5e14 100644
--- a/Jellyfin.Server/CoreAppHost.cs
+++ b/Jellyfin.Server/CoreAppHost.cs
@@ -13,6 +13,7 @@ using Jellyfin.Server.Implementations.Events;
using Jellyfin.Server.Implementations.Users;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.BaseItemManager;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
@@ -37,21 +38,18 @@ namespace Jellyfin.Server
/// <param name="loggerFactory">The <see cref="ILoggerFactory" /> to be used by the <see cref="CoreAppHost" />.</param>
/// <param name="options">The <see cref="StartupOptions" /> to be used by the <see cref="CoreAppHost" />.</param>
/// <param name="fileSystem">The <see cref="IFileSystem" /> to be used by the <see cref="CoreAppHost" />.</param>
- /// <param name="networkManager">The <see cref="INetworkManager" /> to be used by the <see cref="CoreAppHost" />.</param>
/// <param name="collection">The <see cref="IServiceCollection"/> to be used by the <see cref="CoreAppHost"/>.</param>
public CoreAppHost(
IServerApplicationPaths applicationPaths,
ILoggerFactory loggerFactory,
IStartupOptions options,
IFileSystem fileSystem,
- INetworkManager networkManager,
IServiceCollection collection)
: base(
applicationPaths,
loggerFactory,
options,
fileSystem,
- networkManager,
collection)
{
}
@@ -76,6 +74,7 @@ namespace Jellyfin.Server
options => options.UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}"));
ServiceCollection.AddEventServices();
+ ServiceCollection.AddSingleton<IBaseItemManager, BaseItemManager>();
ServiceCollection.AddSingleton<IEventManager, EventManager>();
ServiceCollection.AddSingleton<JellyfinDbProvider>();
@@ -83,13 +82,11 @@ namespace Jellyfin.Server
ServiceCollection.AddSingleton<IUserManager, UserManager>();
ServiceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>();
- ServiceCollection.AddScoped<IWebSocketListener, SessionWebSocketListener>();
- ServiceCollection.AddScoped<IWebSocketListener, ActivityLogWebSocketListener>();
- ServiceCollection.AddScoped<IWebSocketListener, ScheduledTasksWebSocketListener>();
- ServiceCollection.AddScoped<IWebSocketListener, SessionInfoWebSocketListener>();
-
- // TODO fix circular dependency on IWebSocketManager
- ServiceCollection.AddScoped(serviceProvider => new Lazy<IEnumerable<IWebSocketListener>>(serviceProvider.GetRequiredService<IEnumerable<IWebSocketListener>>));
+ // TODO search the assemblies instead of adding them manually?
+ ServiceCollection.AddSingleton<IWebSocketListener, SessionWebSocketListener>();
+ ServiceCollection.AddSingleton<IWebSocketListener, ActivityLogWebSocketListener>();
+ ServiceCollection.AddSingleton<IWebSocketListener, ScheduledTasksWebSocketListener>();
+ ServiceCollection.AddSingleton<IWebSocketListener, SessionInfoWebSocketListener>();
base.RegisterServices();
}
diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
index c7fbfa4d0..6bf6f383f 100644
--- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using Jellyfin.Networking.Configuration;
using Jellyfin.Server.Middleware;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Builder;
@@ -24,8 +25,8 @@ namespace Jellyfin.Server.Extensions
// Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.),
// specifying the Swagger JSON endpoint.
- var baseUrl = serverConfigurationManager.Configuration.BaseUrl.Trim('/');
- var apiDocBaseUrl = serverConfigurationManager.Configuration.BaseUrl;
+ var baseUrl = serverConfigurationManager.GetNetworkConfiguration().BaseUrl.Trim('/');
+ var apiDocBaseUrl = serverConfigurationManager.GetNetworkConfiguration().BaseUrl;
if (!string.IsNullOrEmpty(baseUrl))
{
baseUrl += '/';
diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
index cc98955df..b256c869c 100644
--- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
@@ -15,8 +15,11 @@ using Jellyfin.Api.Auth.IgnoreParentalControlPolicy;
using Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy;
using Jellyfin.Api.Auth.LocalAccessPolicy;
using Jellyfin.Api.Auth.RequiresElevationPolicy;
+using Jellyfin.Api.Auth.SyncPlayAccessPolicy;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Controllers;
+using Jellyfin.Api.ModelBinders;
+using Jellyfin.Data.Enums;
using Jellyfin.Server.Configuration;
using Jellyfin.Server.Filters;
using Jellyfin.Server.Formatters;
@@ -57,6 +60,7 @@ namespace Jellyfin.Server.Extensions
serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessHandler>();
serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessOrRequiresElevationHandler>();
serviceCollection.AddSingleton<IAuthorizationHandler, RequiresElevationHandler>();
+ serviceCollection.AddSingleton<IAuthorizationHandler, SyncPlayAccessHandler>();
return serviceCollection.AddAuthorizationCore(options =>
{
options.AddPolicy(
@@ -122,6 +126,20 @@ namespace Jellyfin.Server.Extensions
policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
policy.AddRequirements(new RequiresElevationRequirement());
});
+ options.AddPolicy(
+ Policies.SyncPlayAccess,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.JoinGroups));
+ });
+ options.AddPolicy(
+ Policies.SyncPlayCreateGroupAccess,
+ policy =>
+ {
+ policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication);
+ policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.CreateAndJoinGroups));
+ });
});
}
@@ -151,11 +169,19 @@ namespace Jellyfin.Server.Extensions
.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
- for (var i = 0; i < knownProxies.Count; i++)
+ if (knownProxies.Count == 0)
{
- if (IPAddress.TryParse(knownProxies[i], out var address))
+ options.KnownNetworks.Clear();
+ options.KnownProxies.Clear();
+ }
+ else
+ {
+ for (var i = 0; i < knownProxies.Count; i++)
{
- options.KnownProxies.Add(address);
+ if (IPAddress.TryParse(knownProxies[i], out var address))
+ {
+ options.KnownProxies.Add(address);
+ }
}
}
})
@@ -169,6 +195,8 @@ namespace Jellyfin.Server.Extensions
opts.OutputFormatters.Add(new CssOutputFormatter());
opts.OutputFormatters.Add(new XmlOutputFormatter());
+
+ opts.ModelBinderProviders.Insert(0, new NullableEnumModelBinderProvider());
})
// Clear app parts to avoid other assemblies being picked up
@@ -233,18 +261,6 @@ namespace Jellyfin.Server.Extensions
Description = "API key header parameter"
});
- var securitySchemeRef = new OpenApiSecurityScheme
- {
- Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = AuthenticationSchemes.CustomAuthentication },
- };
-
- // TODO: Apply this with an operation filter instead of globally
- // https://github.com/domaindrivendev/Swashbuckle.AspNetCore#add-security-definitions-and-requirements
- c.AddSecurityRequirement(new OpenApiSecurityRequirement
- {
- { securitySchemeRef, Array.Empty<string>() }
- });
-
// Add all xml doc files to swagger generator.
var xmlFiles = Directory.GetFiles(
AppContext.BaseDirectory,
@@ -274,6 +290,7 @@ namespace Jellyfin.Server.Extensions
// TODO - remove when all types are supported in System.Text.Json
c.AddSwaggerTypeMappings();
+ c.OperationFilter<SecurityRequirementsOperationFilter>();
c.OperationFilter<FileResponseFilter>();
c.DocumentFilter<WebsocketModelFilter>();
});
@@ -282,20 +299,17 @@ namespace Jellyfin.Server.Extensions
private static void AddSwaggerTypeMappings(this SwaggerGenOptions options)
{
/*
- * TODO remove when System.Text.Json supports non-string keys.
- * Used in Jellyfin.Api.Controller.GetChannels.
+ * TODO remove when System.Text.Json properly supports non-string keys.
+ * Used in BaseItemDto.ImageBlurHashes
*/
options.MapType<Dictionary<ImageType, string>>(() =>
new OpenApiSchema
{
Type = "object",
- Properties = typeof(ImageType).GetEnumNames().ToDictionary(
- name => name,
- name => new OpenApiSchema
- {
- Type = "string",
- Format = "string"
- })
+ AdditionalProperties = new OpenApiSchema
+ {
+ Type = "string"
+ }
});
/*
@@ -309,16 +323,10 @@ namespace Jellyfin.Server.Extensions
name => name,
name => new OpenApiSchema
{
- Type = "object", Properties = new Dictionary<string, OpenApiSchema>
+ Type = "object",
+ AdditionalProperties = new OpenApiSchema
{
- {
- "string",
- new OpenApiSchema
- {
- Type = "string",
- Format = "string"
- }
- }
+ Type = "string"
}
})
});
diff --git a/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
new file mode 100644
index 000000000..802662ce2
--- /dev/null
+++ b/Jellyfin.Server/Filters/SecurityRequirementsOperationFilter.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Api.Constants;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Jellyfin.Server.Filters
+{
+ /// <summary>
+ /// Security requirement operation filter.
+ /// </summary>
+ public class SecurityRequirementsOperationFilter : IOperationFilter
+ {
+ /// <inheritdoc />
+ public void Apply(OpenApiOperation operation, OperationFilterContext context)
+ {
+ var requiredScopes = new List<string>();
+
+ // Add all method scopes.
+ foreach (var attribute in context.MethodInfo.GetCustomAttributes(true))
+ {
+ if (attribute is AuthorizeAttribute authorizeAttribute
+ && authorizeAttribute.Policy != null
+ && !requiredScopes.Contains(authorizeAttribute.Policy, StringComparer.Ordinal))
+ {
+ requiredScopes.Add(authorizeAttribute.Policy);
+ }
+ }
+
+ // Add controller scopes if any.
+ var controllerAttributes = context.MethodInfo.DeclaringType?.GetCustomAttributes(true);
+ if (controllerAttributes != null)
+ {
+ foreach (var attribute in controllerAttributes)
+ {
+ if (attribute is AuthorizeAttribute authorizeAttribute
+ && authorizeAttribute.Policy != null
+ && !requiredScopes.Contains(authorizeAttribute.Policy, StringComparer.Ordinal))
+ {
+ requiredScopes.Add(authorizeAttribute.Policy);
+ }
+ }
+ }
+
+ if (requiredScopes.Count != 0)
+ {
+ if (!operation.Responses.ContainsKey("401"))
+ {
+ operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" });
+ }
+
+ if (!operation.Responses.ContainsKey("403"))
+ {
+ operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" });
+ }
+
+ var scheme = new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ {
+ Type = ReferenceType.SecurityScheme,
+ Id = AuthenticationSchemes.CustomAuthentication
+ }
+ };
+
+ operation.Security = new List<OpenApiSecurityRequirement>
+ {
+ new OpenApiSecurityRequirement
+ {
+ [scheme] = requiredScopes
+ }
+ };
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs
index 9316737bd..c23da2fd6 100644
--- a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs
+++ b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
+using Jellyfin.Networking.Configuration;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
@@ -42,7 +43,7 @@ namespace Jellyfin.Server.Middleware
public async Task Invoke(HttpContext httpContext, IServerConfigurationManager serverConfigurationManager)
{
var localPath = httpContext.Request.Path.ToString();
- var baseUrlPrefix = serverConfigurationManager.Configuration.BaseUrl;
+ var baseUrlPrefix = serverConfigurationManager.GetNetworkConfiguration().BaseUrl;
if (string.Equals(localPath, baseUrlPrefix + "/", StringComparison.OrdinalIgnoreCase)
|| string.Equals(localPath, baseUrlPrefix, StringComparison.OrdinalIgnoreCase)
diff --git a/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs
index 4bda8f273..525cd9ffe 100644
--- a/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs
+++ b/Jellyfin.Server/Middleware/IpBasedAccessValidationMiddleware.cs
@@ -1,5 +1,6 @@
-using System.Linq;
+using System.Net;
using System.Threading.Tasks;
+using Jellyfin.Networking.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
@@ -34,40 +35,40 @@ namespace Jellyfin.Server.Middleware
{
if (httpContext.IsLocal())
{
+ // Running locally.
await _next(httpContext).ConfigureAwait(false);
return;
}
- var remoteIp = httpContext.GetNormalizedRemoteIp();
+ var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback;
- if (serverConfigurationManager.Configuration.EnableRemoteAccess)
+ if (serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess)
{
- var addressFilter = serverConfigurationManager.Configuration.RemoteIPFilter.Where(i => !string.IsNullOrWhiteSpace(i)).ToArray();
+ // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely.
+ // If left blank, all remote addresses will be allowed.
+ var remoteAddressFilter = networkManager.RemoteAddressFilter;
- if (addressFilter.Length > 0 && !networkManager.IsInLocalNetwork(remoteIp))
+ if (remoteAddressFilter.Count > 0 && !networkManager.IsInLocalNetwork(remoteIp))
{
- if (serverConfigurationManager.Configuration.IsRemoteIPFilterBlacklist)
+ // remoteAddressFilter is a whitelist or blacklist.
+ bool isListed = remoteAddressFilter.ContainsAddress(remoteIp);
+ if (!serverConfigurationManager.GetNetworkConfiguration().IsRemoteIPFilterBlacklist)
{
- if (networkManager.IsAddressInSubnets(remoteIp, addressFilter))
- {
- return;
- }
+ // Black list, so flip over.
+ isListed = !isListed;
}
- else
+
+ if (!isListed)
{
- if (!networkManager.IsAddressInSubnets(remoteIp, addressFilter))
- {
- return;
- }
+ // If your name isn't on the list, you arn't coming in.
+ return;
}
}
}
- else
+ else if (!networkManager.IsInLocalNetwork(remoteIp))
{
- if (!networkManager.IsInLocalNetwork(remoteIp))
- {
- return;
- }
+ // Remote not enabled. So everyone should be LAN.
+ return;
}
await _next(httpContext).ConfigureAwait(false);
diff --git a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs
index 9d795145a..8065054a1 100644
--- a/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs
+++ b/Jellyfin.Server/Middleware/LanFilteringMiddleware.cs
@@ -1,6 +1,9 @@
using System;
using System.Linq;
+using System.Net;
using System.Threading.Tasks;
+using Jellyfin.Networking.Configuration;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Http;
@@ -32,45 +35,14 @@ namespace Jellyfin.Server.Middleware
/// <returns>The async task.</returns>
public async Task Invoke(HttpContext httpContext, INetworkManager networkManager, IServerConfigurationManager serverConfigurationManager)
{
- var currentHost = httpContext.Request.Host.ToString();
- var hosts = serverConfigurationManager
- .Configuration
- .LocalNetworkAddresses
- .Select(NormalizeConfiguredLocalAddress)
- .ToList();
+ var host = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback;
- if (hosts.Count == 0)
+ if (!networkManager.IsInLocalNetwork(host) && !serverConfigurationManager.GetNetworkConfiguration().EnableRemoteAccess)
{
- await _next(httpContext).ConfigureAwait(false);
return;
}
- currentHost ??= string.Empty;
-
- if (networkManager.IsInPrivateAddressSpace(currentHost))
- {
- hosts.Add("localhost");
- hosts.Add("127.0.0.1");
-
- if (hosts.All(i => currentHost.IndexOf(i, StringComparison.OrdinalIgnoreCase) == -1))
- {
- return;
- }
- }
-
await _next(httpContext).ConfigureAwait(false);
}
-
- private static string NormalizeConfiguredLocalAddress(string address)
- {
- var add = address.AsSpan().Trim('/');
- int index = add.IndexOf('/');
- if (index != -1)
- {
- add = add.Slice(index + 1);
- }
-
- return add.TrimStart('/').ToString();
- }
}
}
diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs
index aca165408..305660ae6 100644
--- a/Jellyfin.Server/Migrations/MigrationRunner.cs
+++ b/Jellyfin.Server/Migrations/MigrationRunner.cs
@@ -24,7 +24,8 @@ namespace Jellyfin.Server.Migrations
typeof(Routines.MigrateUserDb),
typeof(Routines.ReaddDefaultPluginRepository),
typeof(Routines.MigrateDisplayPreferencesDb),
- typeof(Routines.RemoveDownloadImagesInAdvance)
+ typeof(Routines.RemoveDownloadImagesInAdvance),
+ typeof(Routines.AddPeopleQueryIndex)
};
/// <summary>
diff --git a/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs b/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs
new file mode 100644
index 000000000..2521d9952
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/AddPeopleQueryIndex.cs
@@ -0,0 +1,49 @@
+using System;
+using System.IO;
+using MediaBrowser.Controller;
+using Microsoft.Extensions.Logging;
+using SQLitePCL.pretty;
+
+namespace Jellyfin.Server.Migrations.Routines
+{
+ /// <summary>
+ /// Migration to add table indexes to optimize the Persons query.
+ /// </summary>
+ public class AddPeopleQueryIndex : IMigrationRoutine
+ {
+ private const string DbFilename = "library.db";
+ private readonly ILogger<AddPeopleQueryIndex> _logger;
+ private readonly IServerApplicationPaths _serverApplicationPaths;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AddPeopleQueryIndex"/> class.
+ /// </summary>
+ /// <param name="logger">Instance of the <see cref="ILogger{AddPeopleQueryIndex}"/> interface.</param>
+ /// <param name="serverApplicationPaths">Instance of the <see cref="IServerApplicationPaths"/> interface.</param>
+ public AddPeopleQueryIndex(ILogger<AddPeopleQueryIndex> logger, IServerApplicationPaths serverApplicationPaths)
+ {
+ _logger = logger;
+ _serverApplicationPaths = serverApplicationPaths;
+ }
+
+ /// <inheritdoc />
+ public Guid Id => new Guid("DE009B59-BAAE-428D-A810-F67762DC05B8");
+
+ /// <inheritdoc />
+ public string Name => "AddPeopleQueryIndex";
+
+ /// <inheritdoc />
+ public bool PerformOnNewInstall => true;
+
+ /// <inheritdoc />
+ public void Perform()
+ {
+ var databasePath = Path.Join(_serverApplicationPaths.DataPath, DbFilename);
+ using var connection = SQLite3.Open(databasePath, ConnectionFlags.ReadWrite, null);
+ _logger.LogInformation("Creating index idx_TypedBaseItemsUserDataKeyType");
+ connection.Execute("CREATE INDEX idx_TypedBaseItemsUserDataKeyType ON TypedBaseItems(UserDataKey, Type);");
+ _logger.LogInformation("Creating index idx_PeopleNameListOrder");
+ connection.Execute("CREATE INDEX idx_PeopleNameListOrder ON People(Name, ListOrder);");
+ }
+ }
+} \ No newline at end of file
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
index 8992c281d..af4be5a26 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs
@@ -8,6 +8,7 @@ using System.Text.Json.Serialization;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Server.Implementations;
+using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
@@ -94,6 +95,7 @@ namespace Jellyfin.Server.Migrations.Routines
continue;
}
+ var itemId = new Guid(result[1].ToBlob());
var dtoUserId = new Guid(result[1].ToBlob());
var existingUser = _userManager.GetUserById(dtoUserId);
if (existingUser == null)
@@ -105,8 +107,9 @@ namespace Jellyfin.Server.Migrations.Routines
var chromecastVersion = dto.CustomPrefs.TryGetValue("chromecastVersion", out var version)
? chromecastDict[version]
: ChromecastVersion.Stable;
+ dto.CustomPrefs.Remove("chromecastVersion");
- var displayPreferences = new DisplayPreferences(dtoUserId, result[2].ToString())
+ var displayPreferences = new DisplayPreferences(dtoUserId, itemId, result[2].ToString())
{
IndexBy = Enum.TryParse<IndexingKind>(dto.IndexBy, true, out var indexBy) ? indexBy : (IndexingKind?)null,
ShowBackdrop = dto.ShowBackdrop,
@@ -126,15 +129,24 @@ namespace Jellyfin.Server.Migrations.Routines
TvHome = dto.CustomPrefs.TryGetValue("tvhome", out var home) ? home : string.Empty
};
+ dto.CustomPrefs.Remove("skipForwardLength");
+ dto.CustomPrefs.Remove("skipBackLength");
+ dto.CustomPrefs.Remove("enableNextVideoInfoOverlay");
+ dto.CustomPrefs.Remove("dashboardtheme");
+ dto.CustomPrefs.Remove("tvhome");
+
for (int i = 0; i < 7; i++)
{
- dto.CustomPrefs.TryGetValue("homesection" + i, out var homeSection);
+ var key = "homesection" + i;
+ dto.CustomPrefs.TryGetValue(key, out var homeSection);
displayPreferences.HomeSections.Add(new HomeSection
{
Order = i,
Type = Enum.TryParse<HomeSectionType>(homeSection, true, out var type) ? type : defaults[i]
});
+
+ dto.CustomPrefs.Remove(key);
}
var defaultLibraryPrefs = new ItemDisplayPreferences(displayPreferences.UserId, Guid.Empty, displayPreferences.Client)
@@ -149,12 +161,12 @@ namespace Jellyfin.Server.Migrations.Routines
foreach (var key in dto.CustomPrefs.Keys.Where(key => key.StartsWith("landing-", StringComparison.Ordinal)))
{
- if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var itemId))
+ if (!Guid.TryParse(key.AsSpan().Slice("landing-".Length), out var landingItemId))
{
continue;
}
- var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client)
+ var libraryDisplayPreferences = new ItemDisplayPreferences(displayPreferences.UserId, landingItemId, displayPreferences.Client)
{
SortBy = dto.SortBy ?? "SortName",
SortOrder = dto.SortOrder,
@@ -167,9 +179,15 @@ namespace Jellyfin.Server.Migrations.Routines
libraryDisplayPreferences.ViewType = viewType;
}
+ dto.CustomPrefs.Remove(key);
dbContext.ItemDisplayPreferences.Add(libraryDisplayPreferences);
}
+ foreach (var (key, value) in dto.CustomPrefs)
+ {
+ dbContext.Add(new CustomItemDisplayPreferences(displayPreferences.UserId, itemId, displayPreferences.Client, key, value));
+ }
+
dbContext.Add(displayPreferences);
}
diff --git a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
index b281b5cc0..394f14d63 100644
--- a/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
+++ b/Jellyfin.Server/Migrations/Routines/ReaddDefaultPluginRepository.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates;
@@ -46,4 +46,4 @@ namespace Jellyfin.Server.Migrations.Routines
}
}
}
-} \ No newline at end of file
+}
diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
index 42b87ec5f..9137ea234 100644
--- a/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
+++ b/Jellyfin.Server/Migrations/Routines/RemoveDownloadImagesInAdvance.cs
@@ -35,8 +35,14 @@ namespace Jellyfin.Server.Migrations.Routines
_logger.LogInformation("Removing 'RemoveDownloadImagesInAdvance' settings in all the libraries");
foreach (var virtualFolder in virtualFolders)
{
+ // Some virtual folders don't have a proper item id.
+ if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
+ {
+ continue;
+ }
+
var libraryOptions = virtualFolder.LibraryOptions;
- var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(virtualFolder.ItemId);
+ var collectionFolder = (CollectionFolder)_libraryManager.GetItemById(folderId);
// 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 97a51c202..a1a7a3053 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -12,9 +12,9 @@ using System.Threading.Tasks;
using CommandLine;
using Emby.Server.Implementations;
using Emby.Server.Implementations.IO;
-using Emby.Server.Implementations.Networking;
using Jellyfin.Api.Controllers;
using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Extensions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
@@ -106,6 +106,10 @@ namespace Jellyfin.Server
// $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager
Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath);
+ // Enable cl-va P010 interop for tonemapping on Intel VAAPI
+ Environment.SetEnvironmentVariable("NEOReadDebugKeys", "1");
+ Environment.SetEnvironmentVariable("EnableExtendedVaFormats", "1");
+
await InitLoggingConfigFile(appPaths).ConfigureAwait(false);
// Create an instance of the application configuration to use for application startup
@@ -161,7 +165,6 @@ namespace Jellyfin.Server
_loggerFactory,
options,
new ManagedFileSystem(_loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths),
- new NetworkManager(_loggerFactory.CreateLogger<NetworkManager>()),
serviceCollection);
try
@@ -272,53 +275,17 @@ namespace Jellyfin.Server
return builder
.UseKestrel((builderContext, options) =>
{
- var addresses = appHost.ServerConfigurationManager
- .Configuration
- .LocalNetworkAddresses
- .Select(x => appHost.NormalizeConfiguredLocalAddress(x))
- .Where(i => i != null)
- .ToHashSet();
- if (addresses.Count > 0 && !addresses.Contains(IPAddress.Any))
- {
- if (!addresses.Contains(IPAddress.Loopback))
- {
- // we must listen on loopback for LiveTV to function regardless of the settings
- addresses.Add(IPAddress.Loopback);
- }
-
- foreach (var address in addresses)
- {
- _logger.LogInformation("Kestrel listening on {IpAddress}", address);
- options.Listen(address, appHost.HttpPort);
+ var addresses = appHost.NetManager.GetAllBindInterfaces();
- if (appHost.ListenWithHttps)
- {
- options.Listen(
- address,
- appHost.HttpsPort,
- listenOptions => listenOptions.UseHttps(appHost.Certificate));
- }
- else if (builderContext.HostingEnvironment.IsDevelopment())
- {
- try
- {
- options.Listen(address, appHost.HttpsPort, listenOptions => listenOptions.UseHttps());
- }
- catch (InvalidOperationException ex)
- {
- _logger.LogError(ex, "Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted.");
- }
- }
- }
- }
- else
+ bool flagged = false;
+ foreach (IPObject netAdd in addresses)
{
- _logger.LogInformation("Kestrel listening on all interfaces");
- options.ListenAnyIP(appHost.HttpPort);
-
+ _logger.LogInformation("Kestrel listening on {0}", netAdd);
+ options.Listen(netAdd.Address, appHost.HttpPort);
if (appHost.ListenWithHttps)
{
- options.ListenAnyIP(
+ options.Listen(
+ netAdd.Address,
appHost.HttpsPort,
listenOptions => listenOptions.UseHttps(appHost.Certificate));
}
@@ -326,11 +293,18 @@ namespace Jellyfin.Server
{
try
{
- options.ListenAnyIP(appHost.HttpsPort, listenOptions => listenOptions.UseHttps());
+ options.Listen(
+ netAdd.Address,
+ appHost.HttpsPort,
+ listenOptions => listenOptions.UseHttps());
}
- catch (InvalidOperationException ex)
+ catch (InvalidOperationException)
{
- _logger.LogError(ex, "Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted.");
+ if (!flagged)
+ {
+ _logger.LogWarning("Failed to listen to HTTPS using the ASP.NET Core HTTPS development certificate. Please ensure it has been installed and set as trusted.");
+ flagged = true;
+ }
}
}
}
diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs
index 62ffe174c..aa3ef5350 100644
--- a/Jellyfin.Server/Startup.cs
+++ b/Jellyfin.Server/Startup.cs
@@ -1,8 +1,6 @@
-using System;
-using System.ComponentModel;
using System.Net.Http.Headers;
using System.Net.Mime;
-using Jellyfin.Api.TypeConverters;
+using Jellyfin.Networking.Configuration;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Implementations;
using Jellyfin.Server.Middleware;
@@ -54,7 +52,7 @@ namespace Jellyfin.Server
{
options.HttpsPort = _serverApplicationHost.HttpsPort;
});
- services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.Configuration.KnownProxies);
+ services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration().KnownProxies);
services.AddJellyfinApiSwagger();
@@ -66,10 +64,16 @@ namespace Jellyfin.Server
var productHeader = new ProductInfoHeaderValue(
_serverApplicationHost.Name.Replace(' ', '-'),
_serverApplicationHost.ApplicationVersionString);
+ var acceptJsonHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json, 1.0);
+ var acceptXmlHeader = new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Xml, 0.9);
+ var acceptAnyHeader = new MediaTypeWithQualityHeaderValue("*/*", 0.8);
services
.AddHttpClient(NamedClient.Default, c =>
{
c.DefaultRequestHeaders.UserAgent.Add(productHeader);
+ c.DefaultRequestHeaders.Accept.Add(acceptJsonHeader);
+ c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
+ c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
})
.ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
@@ -77,6 +81,8 @@ namespace Jellyfin.Server
{
c.DefaultRequestHeaders.UserAgent.Add(productHeader);
c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue($"({_serverApplicationHost.ApplicationUserAgentAddress})"));
+ c.DefaultRequestHeaders.Accept.Add(acceptXmlHeader);
+ c.DefaultRequestHeaders.Accept.Add(acceptAnyHeader);
})
.ConfigurePrimaryHttpMessageHandler(x => new DefaultHttpClientHandler());
@@ -98,7 +104,8 @@ namespace Jellyfin.Server
app.UseBaseUrlRedirection();
// Wrap rest of configuration so everything only listens on BaseUrl.
- app.Map(_serverConfigurationManager.Configuration.BaseUrl, mainApp =>
+ var config = _serverConfigurationManager.GetNetworkConfiguration();
+ app.Map(config.BaseUrl, mainApp =>
{
if (env.IsDevelopment())
{
@@ -116,8 +123,7 @@ namespace Jellyfin.Server
mainApp.UseCors();
- if (_serverConfigurationManager.Configuration.RequireHttps
- && _serverApplicationHost.ListenWithHttps)
+ if (config.RequireHttps && _serverApplicationHost.ListenWithHttps)
{
mainApp.UseHttpsRedirection();
}
@@ -127,8 +133,9 @@ namespace Jellyfin.Server
{
var extensionProvider = new FileExtensionContentTypeProvider();
- // subtitles octopus requires .data files.
+ // subtitles octopus requires .data, .mem files.
extensionProvider.Mappings.Add(".data", MediaTypeNames.Application.Octet);
+ extensionProvider.Mappings.Add(".mem", MediaTypeNames.Application.Octet);
mainApp.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(_serverConfigurationManager.ApplicationPaths.WebPath),
@@ -164,9 +171,6 @@ namespace Jellyfin.Server
endpoints.MapHealthChecks("/health");
});
});
-
- // Add type descriptor for legacy datetime parsing.
- TypeDescriptor.AddAttributes(typeof(DateTime?), new TypeConverterAttribute(typeof(DateTimeTypeConverter)));
}
}
}
diff --git a/MediaBrowser.Common/Cryptography/PasswordHash.cs b/MediaBrowser.Common/Cryptography/PasswordHash.cs
index 3e12536ec..3e2eae1c8 100644
--- a/MediaBrowser.Common/Cryptography/PasswordHash.cs
+++ b/MediaBrowser.Common/Cryptography/PasswordHash.cs
@@ -101,13 +101,13 @@ namespace MediaBrowser.Common.Cryptography
// Check if the string also contains a salt
if (splitted.Length - index == 2)
{
- salt = Hex.Decode(splitted[index++]);
- hash = Hex.Decode(splitted[index++]);
+ salt = Convert.FromHexString(splitted[index++]);
+ hash = Convert.FromHexString(splitted[index++]);
}
else
{
salt = Array.Empty<byte>();
- hash = Hex.Decode(splitted[index++]);
+ hash = Convert.FromHexString(splitted[index++]);
}
return new PasswordHash(id, hash, salt, parameters);
@@ -144,11 +144,11 @@ namespace MediaBrowser.Common.Cryptography
if (_salt.Length != 0)
{
str.Append('$')
- .Append(Hex.Encode(_salt, false));
+ .Append(Convert.ToHexString(_salt));
}
return str.Append('$')
- .Append(Hex.Encode(_hash, false)).ToString();
+ .Append(Convert.ToHexString(_hash)).ToString();
}
}
}
diff --git a/MediaBrowser.Common/Hex.cs b/MediaBrowser.Common/Hex.cs
deleted file mode 100644
index 559109f74..000000000
--- a/MediaBrowser.Common/Hex.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-using System;
-using System.Diagnostics.CodeAnalysis;
-
-namespace MediaBrowser.Common
-{
- /// <summary>
- /// Encoding and decoding hex strings.
- /// </summary>
- public static class Hex
- {
- internal const string HexCharsLower = "0123456789abcdef";
- internal const string HexCharsUpper = "0123456789ABCDEF";
-
- internal const int LastHexSymbol = 0x66; // 102: f
-
- /// <summary>
- /// Gets a map from an ASCII char to its hex value shifted,
- /// e.g. <c>b</c> -> 11. 0xFF means it's not a hex symbol.
- /// </summary>
- internal static ReadOnlySpan<byte> HexLookup => new byte[]
- {
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
- 0xFF, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f
- };
-
- /// <summary>
- /// Encodes each element of the specified bytes as its hexadecimal string representation.
- /// </summary>
- /// <param name="bytes">An array of bytes.</param>
- /// <param name="lowercase"><c>true</c> to use lowercase hexadecimal characters; otherwise <c>false</c>.</param>
- /// <returns><c>bytes</c> as a hex string.</returns>
- public static string Encode(ReadOnlySpan<byte> bytes, bool lowercase = true)
- {
- var hexChars = lowercase ? HexCharsLower : HexCharsUpper;
-
- // TODO: use string.Create when it's supports spans
- // Ref: https://github.com/dotnet/corefx/issues/29120
- char[] s = new char[bytes.Length * 2];
- int j = 0;
- for (int i = 0; i < bytes.Length; i++)
- {
- s[j++] = hexChars[bytes[i] >> 4];
- s[j++] = hexChars[bytes[i] & 0x0f];
- }
-
- return new string(s);
- }
-
- /// <summary>
- /// Decodes a hex string into bytes.
- /// </summary>
- /// <param name="str">The <see cref="string" />.</param>
- /// <returns>The decoded bytes.</returns>
- public static byte[] Decode(ReadOnlySpan<char> str)
- {
- if (str.Length == 0)
- {
- return Array.Empty<byte>();
- }
-
- var unHex = HexLookup;
-
- int byteLen = str.Length / 2;
- byte[] bytes = new byte[byteLen];
- int i = 0;
- for (int j = 0; j < byteLen; j++)
- {
- byte a;
- byte b;
- if (str[i] > LastHexSymbol
- || (a = unHex[str[i++]]) == 0xFF
- || str[i] > LastHexSymbol
- || (b = unHex[str[i++]]) == 0xFF)
- {
- ThrowArgumentException(nameof(str));
- break; // Unreachable
- }
-
- bytes[j] = (byte)((a * 16) | b);
- }
-
- return bytes;
- }
-
- [DoesNotReturn]
- private static void ThrowArgumentException(string paramName)
- => throw new ArgumentException("Character is not a hex symbol.", paramName);
- }
-}
diff --git a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
index 06a29a0db..a259cb7bc 100644
--- a/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
+++ b/MediaBrowser.Common/Json/Converters/JsonCommaDelimitedArrayConverter.cs
@@ -43,7 +43,8 @@ namespace MediaBrowser.Common.Json.Converters
}
catch (FormatException)
{
- // TODO log when upgraded to .Net5
+ // TODO log when upgraded to .Net6
+ // https://github.com/dotnet/runtime/issues/42975
// _logger.LogWarning(e, "Error converting value.");
}
}
diff --git a/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs b/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs
index d35a761f3..52e08d071 100644
--- a/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs
+++ b/MediaBrowser.Common/Json/Converters/JsonGuidConverter.cs
@@ -11,10 +11,23 @@ namespace MediaBrowser.Common.Json.Converters
{
/// <inheritdoc />
public override Guid Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- => new Guid(reader.GetString());
+ {
+ var guidStr = reader.GetString();
+
+ return guidStr == null ? Guid.Empty : new Guid(guidStr);
+ }
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, Guid value, JsonSerializerOptions options)
- => writer.WriteStringValue(value);
+ {
+ if (value == Guid.Empty)
+ {
+ writer.WriteNullValue();
+ }
+ else
+ {
+ writer.WriteStringValue(value);
+ }
+ }
}
}
diff --git a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs
new file mode 100644
index 000000000..75fbcea1f
--- /dev/null
+++ b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverter.cs
@@ -0,0 +1,75 @@
+using System;
+using System.ComponentModel;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+ /// <summary>
+ /// Convert Pipe delimited string to array of type.
+ /// </summary>
+ /// <typeparam name="T">Type to convert to.</typeparam>
+ public class JsonPipeDelimitedArrayConverter<T> : JsonConverter<T[]>
+ {
+ private readonly TypeConverter _typeConverter;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JsonPipeDelimitedArrayConverter{T}"/> class.
+ /// </summary>
+ public JsonPipeDelimitedArrayConverter()
+ {
+ _typeConverter = TypeDescriptor.GetConverter(typeof(T));
+ }
+
+ /// <inheritdoc />
+ public override T[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ var stringEntries = reader.GetString()?.Split('|', StringSplitOptions.RemoveEmptyEntries);
+ if (stringEntries == null || stringEntries.Length == 0)
+ {
+ return Array.Empty<T>();
+ }
+
+ var parsedValues = new object[stringEntries.Length];
+ var convertedCount = 0;
+ for (var i = 0; i < stringEntries.Length; i++)
+ {
+ try
+ {
+ parsedValues[i] = _typeConverter.ConvertFrom(stringEntries[i].Trim());
+ convertedCount++;
+ }
+ catch (FormatException)
+ {
+ // TODO log when upgraded to .Net6
+ // https://github.com/dotnet/runtime/issues/42975
+ // _logger.LogWarning(e, "Error converting value.");
+ }
+ }
+
+ var typedValues = new T[convertedCount];
+ var typedValueIndex = 0;
+ for (var i = 0; i < stringEntries.Length; i++)
+ {
+ if (parsedValues[i] != null)
+ {
+ typedValues.SetValue(parsedValues[i], typedValueIndex);
+ typedValueIndex++;
+ }
+ }
+
+ return typedValues;
+ }
+
+ return JsonSerializer.Deserialize<T[]>(ref reader, options);
+ }
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, T[] value, JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value, options);
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs
new file mode 100644
index 000000000..5e77223ef
--- /dev/null
+++ b/MediaBrowser.Common/Json/Converters/JsonPipeDelimitedArrayConverterFactory.cs
@@ -0,0 +1,28 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+ /// <summary>
+ /// Json Pipe delimited array converter factory.
+ /// </summary>
+ /// <remarks>
+ /// This must be applied as an attribute, adding to the JsonConverter list causes stack overflow.
+ /// </remarks>
+ public class JsonPipeDelimitedArrayConverterFactory : JsonConverterFactory
+ {
+ /// <inheritdoc />
+ public override bool CanConvert(Type typeToConvert)
+ {
+ return true;
+ }
+
+ /// <inheritdoc />
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
+ {
+ var structType = typeToConvert.GetElementType() ?? typeToConvert.GenericTypeArguments[0];
+ return (JsonConverter)Activator.CreateInstance(typeof(JsonPipeDelimitedArrayConverter<>).MakeGenericType(structType));
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Json/Converters/JsonVersionConverter.cs b/MediaBrowser.Common/Json/Converters/JsonVersionConverter.cs
new file mode 100644
index 000000000..37e6f64e3
--- /dev/null
+++ b/MediaBrowser.Common/Json/Converters/JsonVersionConverter.cs
@@ -0,0 +1,20 @@
+using System;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace MediaBrowser.Common.Json.Converters
+{
+ /// <summary>
+ /// Converts a Version object or value to/from JSON.
+ /// </summary>
+ public class JsonVersionConverter : JsonConverter<Version>
+ {
+ /// <inheritdoc />
+ public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ => new Version(reader.GetString());
+
+ /// <inheritdoc />
+ public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options)
+ => writer.WriteStringValue(value.ToString());
+ }
+}
diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs
index 6605ae962..c5050a21d 100644
--- a/MediaBrowser.Common/Json/JsonDefaults.cs
+++ b/MediaBrowser.Common/Json/JsonDefaults.cs
@@ -40,6 +40,7 @@ namespace MediaBrowser.Common.Json
};
options.Converters.Add(new JsonGuidConverter());
+ options.Converters.Add(new JsonVersionConverter());
options.Converters.Add(new JsonStringEnumConverter());
options.Converters.Add(new JsonNullableStructConverterFactory());
diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj
index c2145aec5..be5e7f5b4 100644
--- a/MediaBrowser.Common/MediaBrowser.Common.csproj
+++ b/MediaBrowser.Common/MediaBrowser.Common.csproj
@@ -20,7 +20,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="5.0.0" />
- <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
+ <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.Net.Http.Headers" Version="2.2.8" />
</ItemGroup>
diff --git a/MediaBrowser.Common/Net/INetworkManager.cs b/MediaBrowser.Common/Net/INetworkManager.cs
index a0330afef..b6c390d23 100644
--- a/MediaBrowser.Common/Net/INetworkManager.cs
+++ b/MediaBrowser.Common/Net/INetworkManager.cs
@@ -1,97 +1,233 @@
-#pragma warning disable CS1591
-
+#nullable enable
using System;
using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.Net;
using System.Net.NetworkInformation;
+using MediaBrowser.Common.Net;
+using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Common.Net
{
+ /// <summary>
+ /// Interface for the NetworkManager class.
+ /// </summary>
public interface INetworkManager
{
+ /// <summary>
+ /// Event triggered on network changes.
+ /// </summary>
event EventHandler NetworkChanged;
/// <summary>
- /// Gets or sets a function to return the list of user defined LAN addresses.
+ /// Gets the published server urls list.
+ /// </summary>
+ Dictionary<IPNetAddress, string> PublishedServerUrls { get; }
+
+ /// <summary>
+ /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal.
+ /// </summary>
+ bool TrustAllIP6Interfaces { get; }
+
+ /// <summary>
+ /// Gets the remote address filter.
+ /// </summary>
+ Collection<IPObject> RemoteAddressFilter { get; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether iP6 is enabled.
+ /// </summary>
+ bool IsIP6Enabled { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether iP4 is enabled.
+ /// </summary>
+ bool IsIP4Enabled { get; set; }
+
+ /// <summary>
+ /// Calculates the list of interfaces to use for Kestrel.
+ /// </summary>
+ /// <returns>A Collection{IPObject} object containing all the interfaces to bind.
+ /// If all the interfaces are specified, and none are excluded, it returns zero items
+ /// to represent any address.</returns>
+ /// <param name="individualInterfaces">When false, return <see cref="IPAddress.Any"/> or <see cref="IPAddress.IPv6Any"/> for all interfaces.</param>
+ Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false);
+
+ /// <summary>
+ /// Returns a collection containing the loopback interfaces.
+ /// </summary>
+ /// <returns>Collection{IPObject}.</returns>
+ Collection<IPObject> GetLoopbacks();
+
+ /// <summary>
+ /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+ /// If no bind addresses are specified, an internal interface address is selected.
+ /// The priority of selection is as follows:-
+ ///
+ /// The value contained in the startup parameter --published-server-url.
+ ///
+ /// If the user specified custom subnet overrides, the correct subnet for the source address.
+ ///
+ /// If the user specified bind interfaces to use:-
+ /// The bind interface that contains the source subnet.
+ /// The first bind interface specified that suits best first the source's endpoint. eg. external or internal.
+ ///
+ /// If the source is from a public subnet address range and the user hasn't specified any bind addresses:-
+ /// The first public interface that isn't a loopback and contains the source subnet.
+ /// The first public interface that isn't a loopback. Priority is given to interfaces with gateways.
+ /// An internal interface if there are no public ip addresses.
+ ///
+ /// If the source is from a private subnet address range and the user hasn't specified any bind addresses:-
+ /// The first private interface that contains the source subnet.
+ /// The first private interface that isn't a loopback. Priority is given to interfaces with gateways.
+ ///
+ /// If no interfaces meet any of these criteria, then a loopback address is returned.
+ ///
+ /// Interface that have been specifically excluded from binding are not used in any of the calculations.
+ /// </summary>
+ /// <param name="source">Source of the request.</param>
+ /// <param name="port">Optional port returned, if it's part of an override.</param>
+ /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+ string GetBindInterface(IPObject source, out int? port);
+
+ /// <summary>
+ /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+ /// If no bind addresses are specified, an internal interface address is selected.
+ /// (See <see cref="GetBindInterface(IPObject, out int?)"/>.
+ /// </summary>
+ /// <param name="source">Source of the request.</param>
+ /// <param name="port">Optional port returned, if it's part of an override.</param>
+ /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+ string GetBindInterface(HttpRequest source, out int? port);
+
+ /// <summary>
+ /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+ /// If no bind addresses are specified, an internal interface address is selected.
+ /// (See <see cref="GetBindInterface(IPObject, out int?)"/>.
+ /// </summary>
+ /// <param name="source">IP address of the request.</param>
+ /// <param name="port">Optional port returned, if it's part of an override.</param>
+ /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+ string GetBindInterface(IPAddress source, out int? port);
+
+ /// <summary>
+ /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo)
+ /// If no bind addresses are specified, an internal interface address is selected.
+ /// (See <see cref="GetBindInterface(IPObject, out int?)"/>.
+ /// </summary>
+ /// <param name="source">Source of the request.</param>
+ /// <param name="port">Optional port returned, if it's part of an override.</param>
+ /// <returns>IP Address to use, or loopback address if all else fails.</returns>
+ string GetBindInterface(string source, out int? port);
+
+ /// <summary>
+ /// Checks to see if the ip address is specifically excluded in LocalNetworkAddresses.
+ /// </summary>
+ /// <param name="address">IP address to check.</param>
+ /// <returns>True if it is.</returns>
+ bool IsExcludedInterface(IPAddress address);
+
+ /// <summary>
+ /// Get a list of all the MAC addresses associated with active interfaces.
+ /// </summary>
+ /// <returns>List of MAC addresses.</returns>
+ IReadOnlyCollection<PhysicalAddress> GetMacAddresses();
+
+ /// <summary>
+ /// Checks to see if the IP Address provided matches an interface that has a gateway.
+ /// </summary>
+ /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param>
+ /// <returns>Result of the check.</returns>
+ bool IsGatewayInterface(IPObject? addressObj);
+
+ /// <summary>
+ /// Checks to see if the IP Address provided matches an interface that has a gateway.
/// </summary>
- Func<string[]> LocalSubnetsFn { get; set; }
+ /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param>
+ /// <returns>Result of the check.</returns>
+ bool IsGatewayInterface(IPAddress? addressObj);
/// <summary>
- /// Gets a random port TCP number that is currently available.
+ /// Returns true if the address is a private address.
+ /// The configuration option TrustIP6Interfaces overrides this functions behaviour.
/// </summary>
- /// <returns>System.Int32.</returns>
- int GetRandomUnusedTcpPort();
+ /// <param name="address">Address to check.</param>
+ /// <returns>True or False.</returns>
+ bool IsPrivateAddressRange(IPObject address);
/// <summary>
- /// Gets a random port UDP number that is currently available.
+ /// Returns true if the address is part of the user defined LAN.
+ /// The configuration option TrustIP6Interfaces overrides this functions behaviour.
/// </summary>
- /// <returns>System.Int32.</returns>
- int GetRandomUnusedUdpPort();
+ /// <param name="address">IP to check.</param>
+ /// <returns>True if endpoint is within the LAN range.</returns>
+ bool IsInLocalNetwork(string address);
/// <summary>
- /// Returns the MAC Address from first Network Card in Computer.
+ /// Returns true if the address is part of the user defined LAN.
+ /// The configuration option TrustIP6Interfaces overrides this functions behaviour.
/// </summary>
- /// <returns>The MAC Address.</returns>
- List<PhysicalAddress> GetMacAddresses();
+ /// <param name="address">IP to check.</param>
+ /// <returns>True if endpoint is within the LAN range.</returns>
+ bool IsInLocalNetwork(IPObject address);
/// <summary>
- /// Determines whether [is in private address space] [the specified endpoint].
+ /// Returns true if the address is part of the user defined LAN.
+ /// The configuration option TrustIP6Interfaces overrides this functions behaviour.
/// </summary>
- /// <param name="endpoint">The endpoint.</param>
- /// <returns><c>true</c> if [is in private address space] [the specified endpoint]; otherwise, <c>false</c>.</returns>
- bool IsInPrivateAddressSpace(string endpoint);
+ /// <param name="address">IP to check.</param>
+ /// <returns>True if endpoint is within the LAN range.</returns>
+ bool IsInLocalNetwork(IPAddress address);
/// <summary>
- /// Determines whether [is in private address space 10.x.x.x] [the specified endpoint] and exists in the subnets returned by GetSubnets().
+ /// Attempts to convert the token to an IP address, permitting for interface descriptions and indexes.
+ /// eg. "eth1", or "TP-LINK Wireless USB Adapter".
/// </summary>
- /// <param name="endpoint">The endpoint.</param>
- /// <returns><c>true</c> if [is in private address space 10.x.x.x] [the specified endpoint]; otherwise, <c>false</c>.</returns>
- bool IsInPrivateAddressSpaceAndLocalSubnet(string endpoint);
+ /// <param name="token">Token to parse.</param>
+ /// <param name="result">Resultant object's ip addresses, if successful.</param>
+ /// <returns>Success of the operation.</returns>
+ bool TryParseInterface(string token, out Collection<IPObject>? result);
/// <summary>
- /// Determines whether [is in local network] [the specified endpoint].
+ /// Parses an array of strings into a Collection{IPObject}.
/// </summary>
- /// <param name="endpoint">The endpoint.</param>
- /// <returns><c>true</c> if [is in local network] [the specified endpoint]; otherwise, <c>false</c>.</returns>
- bool IsInLocalNetwork(string endpoint);
+ /// <param name="values">Values to parse.</param>
+ /// <param name="negated">When true, only include values beginning with !. When false, ignore ! values.</param>
+ /// <returns>IPCollection object containing the value strings.</returns>
+ Collection<IPObject> CreateIPCollection(string[] values, bool negated = false);
/// <summary>
- /// Investigates an caches a list of interface addresses, excluding local link and LAN excluded addresses.
+ /// Returns all the internal Bind interface addresses.
/// </summary>
- /// <returns>The list of ipaddresses.</returns>
- IPAddress[] GetLocalIpAddresses();
+ /// <returns>An internal list of interfaces addresses.</returns>
+ Collection<IPObject> GetInternalBindAddresses();
/// <summary>
- /// Checks if the given address falls within the ranges given in [subnets]. The addresses in subnets can be hosts or subnets in the CIDR format.
+ /// Checks to see if an IP address is still a valid interface address.
/// </summary>
- /// <param name="addressString">The address to check.</param>
- /// <param name="subnets">If true, check against addresses in the LAN settings surrounded by brackets ([]).</param>
- /// <returns><c>true</c>if the address is in at least one of the given subnets, <c>false</c> otherwise.</returns>
- bool IsAddressInSubnets(string addressString, string[] subnets);
+ /// <param name="address">IP address to check.</param>
+ /// <returns>True if it is.</returns>
+ bool IsValidInterfaceAddress(IPAddress address);
/// <summary>
- /// Returns true if address is in the LAN list in the config file.
+ /// Returns true if the IP address is in the excluded list.
/// </summary>
- /// <param name="address">The address to check.</param>
- /// <param name="excludeInterfaces">If true, check against addresses in the LAN settings which have [] arroud and return true if it matches the address give in address.</param>
- /// <param name="excludeRFC">If true, returns false if address is in the 127.x.x.x or 169.128.x.x range.</param>
- /// <returns><c>false</c>if the address isn't in the LAN list, <c>true</c> if the address has been defined as a LAN address.</returns>
- bool IsAddressInSubnets(IPAddress address, bool excludeInterfaces, bool excludeRFC);
+ /// <param name="ip">IP to check.</param>
+ /// <returns>True if excluded.</returns>
+ bool IsExcluded(IPAddress ip);
/// <summary>
- /// Checks if address is in the LAN list in the config file.
+ /// Returns true if the IP address is in the excluded list.
/// </summary>
- /// <param name="address1">Source address to check.</param>
- /// <param name="address2">Destination address to check against.</param>
- /// <param name="subnetMask">Destination subnet to check against.</param>
- /// <returns><c>true/false</c>depending on whether address1 is in the same subnet as IPAddress2 with subnetMask.</returns>
- bool IsInSameSubnet(IPAddress address1, IPAddress address2, IPAddress subnetMask);
+ /// <param name="ip">IP to check.</param>
+ /// <returns>True if excluded.</returns>
+ bool IsExcluded(EndPoint ip);
/// <summary>
- /// Returns the subnet mask of an interface with the given address.
+ /// Gets the filtered LAN ip addresses.
/// </summary>
- /// <param name="address">The address to check.</param>
- /// <returns>Returns the subnet mask of an interface with the given address, or null if an interface match cannot be found.</returns>
- IPAddress GetLocalIpSubnetMask(IPAddress address);
+ /// <param name="filter">Optional filter for the list.</param>
+ /// <returns>Returns a filtered list of LAN addresses.</returns>
+ Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null);
}
}
diff --git a/MediaBrowser.Common/Net/IPHost.cs b/MediaBrowser.Common/Net/IPHost.cs
new file mode 100644
index 000000000..4cede9ab1
--- /dev/null
+++ b/MediaBrowser.Common/Net/IPHost.cs
@@ -0,0 +1,445 @@
+#nullable enable
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+
+namespace MediaBrowser.Common.Net
+{
+ /// <summary>
+ /// Object that holds a host name.
+ /// </summary>
+ public class IPHost : IPObject
+ {
+ /// <summary>
+ /// Gets or sets timeout value before resolve required, in minutes.
+ /// </summary>
+ public const int Timeout = 30;
+
+ /// <summary>
+ /// Represents an IPHost that has no value.
+ /// </summary>
+ public static readonly IPHost None = new IPHost(string.Empty, IPAddress.None);
+
+ /// <summary>
+ /// Time when last resolved in ticks.
+ /// </summary>
+ private DateTime? _lastResolved = null;
+
+ /// <summary>
+ /// Gets the IP Addresses, attempting to resolve the name, if there are none.
+ /// </summary>
+ private IPAddress[] _addresses;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IPHost"/> class.
+ /// </summary>
+ /// <param name="name">Host name to assign.</param>
+ public IPHost(string name)
+ {
+ HostName = name ?? throw new ArgumentNullException(nameof(name));
+ _addresses = Array.Empty<IPAddress>();
+ Resolved = false;
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IPHost"/> class.
+ /// </summary>
+ /// <param name="name">Host name to assign.</param>
+ /// <param name="address">Address to assign.</param>
+ private IPHost(string name, IPAddress address)
+ {
+ HostName = name ?? throw new ArgumentNullException(nameof(name));
+ _addresses = new IPAddress[] { address ?? throw new ArgumentNullException(nameof(address)) };
+ Resolved = !address.Equals(IPAddress.None);
+ }
+
+ /// <summary>
+ /// Gets or sets the object's first IP address.
+ /// </summary>
+ public override IPAddress Address
+ {
+ get
+ {
+ return ResolveHost() ? this[0] : IPAddress.None;
+ }
+
+ set
+ {
+ // Not implemented, as a host's address is determined by DNS.
+ throw new NotImplementedException("The address of a host is determined by DNS.");
+ }
+ }
+
+ /// <summary>
+ /// Gets or sets the object's first IP's subnet prefix.
+ /// The setter does nothing, but shouldn't raise an exception.
+ /// </summary>
+ public override byte PrefixLength
+ {
+ get
+ {
+ return (byte)(ResolveHost() ? 128 : 32);
+ }
+
+ set
+ {
+ // Not implemented, as a host object can only have a prefix length of 128 (IPv6) or 32 (IPv4) prefix length,
+ // which is automatically determined by it's IP type. Anything else is meaningless.
+ }
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether the address has a value.
+ /// </summary>
+ public bool HasAddress => _addresses.Length != 0;
+
+ /// <summary>
+ /// Gets the host name of this object.
+ /// </summary>
+ public string HostName { get; }
+
+ /// <summary>
+ /// Gets a value indicating whether this host has attempted to be resolved.
+ /// </summary>
+ public bool Resolved { get; private set; }
+
+ /// <summary>
+ /// Gets or sets the IP Addresses associated with this object.
+ /// </summary>
+ /// <param name="index">Index of address.</param>
+ public IPAddress this[int index]
+ {
+ get
+ {
+ ResolveHost();
+ return index >= 0 && index < _addresses.Length ? _addresses[index] : IPAddress.None;
+ }
+ }
+
+ /// <summary>
+ /// Attempts to parse the host string.
+ /// </summary>
+ /// <param name="host">Host name to parse.</param>
+ /// <param name="hostObj">Object representing the string, if it has successfully been parsed.</param>
+ /// <returns><c>true</c> if the parsing is successful, <c>false</c> if not.</returns>
+ public static bool TryParse(string host, out IPHost hostObj)
+ {
+ if (!string.IsNullOrEmpty(host))
+ {
+ // See if it's an IPv6 with port address e.g. [::1]:120.
+ int i = host.IndexOf("]:", StringComparison.OrdinalIgnoreCase);
+ if (i != -1)
+ {
+ return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj);
+ }
+ else
+ {
+ // See if it's an IPv6 in [] with no port.
+ i = host.IndexOf(']', StringComparison.OrdinalIgnoreCase);
+ if (i != -1)
+ {
+ return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj);
+ }
+
+ // Is it a host or IPv4 with port?
+ string[] hosts = host.Split(':');
+
+ if (hosts.Length > 2)
+ {
+ hostObj = new IPHost(string.Empty, IPAddress.None);
+ return false;
+ }
+
+ // Remove port from IPv4 if it exists.
+ host = hosts[0];
+
+ if (string.Equals("localhost", host, StringComparison.OrdinalIgnoreCase))
+ {
+ hostObj = new IPHost(host, new IPAddress(Ipv4Loopback));
+ return true;
+ }
+
+ if (IPNetAddress.TryParse(host, out IPNetAddress netIP))
+ {
+ // Host name is an ip address, so fake resolve.
+ hostObj = new IPHost(host, netIP.Address);
+ return true;
+ }
+ }
+
+ // Only thing left is to see if it's a host string.
+ if (!string.IsNullOrEmpty(host))
+ {
+ // Use regular expression as CheckHostName isn't RFC5892 compliant.
+ // Modified from gSkinner's expression at https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation
+ Regex re = new Regex(@"^(?!:\/\/)(?=.{1,255}$)((.{1,63}\.){0,127}(?![0-9]*$)[a-z0-9-]+\.?)$", RegexOptions.IgnoreCase | RegexOptions.Multiline);
+ if (re.Match(host).Success)
+ {
+ hostObj = new IPHost(host);
+ return true;
+ }
+ }
+ }
+
+ hostObj = IPHost.None;
+ return false;
+ }
+
+ /// <summary>
+ /// Attempts to parse the host string.
+ /// </summary>
+ /// <param name="host">Host name to parse.</param>
+ /// <returns>Object representing the string, if it has successfully been parsed.</returns>
+ public static IPHost Parse(string host)
+ {
+ if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res))
+ {
+ return res;
+ }
+
+ throw new InvalidCastException("Host does not contain a valid value. {host}");
+ }
+
+ /// <summary>
+ /// Attempts to parse the host string, ensuring that it resolves only to a specific IP type.
+ /// </summary>
+ /// <param name="host">Host name to parse.</param>
+ /// <param name="family">Addressfamily filter.</param>
+ /// <returns>Object representing the string, if it has successfully been parsed.</returns>
+ public static IPHost Parse(string host, AddressFamily family)
+ {
+ if (!string.IsNullOrEmpty(host) && IPHost.TryParse(host, out IPHost res))
+ {
+ if (family == AddressFamily.InterNetwork)
+ {
+ res.Remove(AddressFamily.InterNetworkV6);
+ }
+ else
+ {
+ res.Remove(AddressFamily.InterNetwork);
+ }
+
+ return res;
+ }
+
+ throw new InvalidCastException("Host does not contain a valid value. {host}");
+ }
+
+ /// <summary>
+ /// Returns the Addresses that this item resolved to.
+ /// </summary>
+ /// <returns>IPAddress Array.</returns>
+ public IPAddress[] GetAddresses()
+ {
+ ResolveHost();
+ return _addresses;
+ }
+
+ /// <inheritdoc/>
+ public override bool Contains(IPAddress address)
+ {
+ if (address != null && !Address.Equals(IPAddress.None))
+ {
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ foreach (var addr in GetAddresses())
+ {
+ if (address.Equals(addr))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public override bool Equals(IPObject? other)
+ {
+ if (other is IPHost otherObj)
+ {
+ // Do we have the name Hostname?
+ if (string.Equals(otherObj.HostName, HostName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ if (!ResolveHost() || !otherObj.ResolveHost())
+ {
+ return false;
+ }
+
+ // Do any of our IP addresses match?
+ foreach (IPAddress addr in _addresses)
+ {
+ foreach (IPAddress otherAddress in otherObj._addresses)
+ {
+ if (addr.Equals(otherAddress))
+ {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public override bool IsIP6()
+ {
+ // Returns true if interfaces are only IP6.
+ if (ResolveHost())
+ {
+ foreach (IPAddress i in _addresses)
+ {
+ if (i.AddressFamily != AddressFamily.InterNetworkV6)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public override string ToString()
+ {
+ // StringBuilder not optimum here.
+ string output = string.Empty;
+ if (_addresses.Length > 0)
+ {
+ bool moreThanOne = _addresses.Length > 1;
+ if (moreThanOne)
+ {
+ output = "[";
+ }
+
+ foreach (var i in _addresses)
+ {
+ if (Address.Equals(IPAddress.None) && Address.AddressFamily == AddressFamily.Unspecified)
+ {
+ output += HostName + ",";
+ }
+ else if (i.Equals(IPAddress.Any))
+ {
+ output += "Any IP4 Address,";
+ }
+ else if (Address.Equals(IPAddress.IPv6Any))
+ {
+ output += "Any IP6 Address,";
+ }
+ else if (i.Equals(IPAddress.Broadcast))
+ {
+ output += "Any Address,";
+ }
+ else
+ {
+ output += $"{i}/32,";
+ }
+ }
+
+ output = output[0..^1];
+
+ if (moreThanOne)
+ {
+ output += "]";
+ }
+ }
+ else
+ {
+ output = HostName;
+ }
+
+ return output;
+ }
+
+ /// <inheritdoc/>
+ public override void Remove(AddressFamily family)
+ {
+ if (ResolveHost())
+ {
+ _addresses = _addresses.Where(p => p.AddressFamily != family).ToArray();
+ }
+ }
+
+ /// <inheritdoc/>
+ public override bool Contains(IPObject address)
+ {
+ // An IPHost cannot contain another IPObject, it can only be equal.
+ return Equals(address);
+ }
+
+ /// <inheritdoc/>
+ protected override IPObject CalculateNetworkAddress()
+ {
+ var netAddr = NetworkAddressOf(this[0], PrefixLength);
+ return new IPNetAddress(netAddr.Address, netAddr.PrefixLength);
+ }
+
+ /// <summary>
+ /// Attempt to resolve the ip address of a host.
+ /// </summary>
+ /// <returns><c>true</c> if any addresses have been resolved, otherwise <c>false</c>.</returns>
+ private bool ResolveHost()
+ {
+ // When was the last time we resolved?
+ if (_lastResolved == null)
+ {
+ _lastResolved = DateTime.UtcNow;
+ }
+
+ // If we haven't resolved before, or our timer has run out...
+ if ((_addresses.Length == 0 && !Resolved) || (DateTime.UtcNow > _lastResolved?.AddMinutes(Timeout)))
+ {
+ _lastResolved = DateTime.UtcNow;
+ ResolveHostInternal().GetAwaiter().GetResult();
+ Resolved = true;
+ }
+
+ return _addresses.Length > 0;
+ }
+
+ /// <summary>
+ /// Task that looks up a Host name and returns its IP addresses.
+ /// </summary>
+ /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
+ private async Task ResolveHostInternal()
+ {
+ if (!string.IsNullOrEmpty(HostName))
+ {
+ // Resolves the host name - so save a DNS lookup.
+ if (string.Equals(HostName, "localhost", StringComparison.OrdinalIgnoreCase))
+ {
+ _addresses = new IPAddress[] { new IPAddress(Ipv4Loopback), new IPAddress(Ipv6Loopback) };
+ return;
+ }
+
+ if (Uri.CheckHostName(HostName).Equals(UriHostNameType.Dns))
+ {
+ try
+ {
+ IPHostEntry ip = await Dns.GetHostEntryAsync(HostName).ConfigureAwait(false);
+ _addresses = ip.AddressList;
+ }
+ catch (SocketException ex)
+ {
+ // Log and then ignore socket errors, as the result value will just be an empty array.
+ Debug.WriteLine("GetHostEntryAsync failed with {Message}.", ex.Message);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Net/IPNetAddress.cs b/MediaBrowser.Common/Net/IPNetAddress.cs
new file mode 100644
index 000000000..a6f5fe4b3
--- /dev/null
+++ b/MediaBrowser.Common/Net/IPNetAddress.cs
@@ -0,0 +1,277 @@
+#nullable enable
+using System;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MediaBrowser.Common.Net
+{
+ /// <summary>
+ /// An object that holds and IP address and subnet mask.
+ /// </summary>
+ public class IPNetAddress : IPObject
+ {
+ /// <summary>
+ /// Represents an IPNetAddress that has no value.
+ /// </summary>
+ public static readonly IPNetAddress None = new IPNetAddress(IPAddress.None);
+
+ /// <summary>
+ /// IPv4 multicast address.
+ /// </summary>
+ public static readonly IPAddress SSDPMulticastIPv4 = IPAddress.Parse("239.255.255.250");
+
+ /// <summary>
+ /// IPv6 local link multicast address.
+ /// </summary>
+ public static readonly IPAddress SSDPMulticastIPv6LinkLocal = IPAddress.Parse("ff02::C");
+
+ /// <summary>
+ /// IPv6 site local multicast address.
+ /// </summary>
+ public static readonly IPAddress SSDPMulticastIPv6SiteLocal = IPAddress.Parse("ff05::C");
+
+ /// <summary>
+ /// IP4Loopback address host.
+ /// </summary>
+ public static readonly IPNetAddress IP4Loopback = IPNetAddress.Parse("127.0.0.1/32");
+
+ /// <summary>
+ /// IP6Loopback address host.
+ /// </summary>
+ public static readonly IPNetAddress IP6Loopback = IPNetAddress.Parse("::1");
+
+ /// <summary>
+ /// Object's IP address.
+ /// </summary>
+ private IPAddress _address;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IPNetAddress"/> class.
+ /// </summary>
+ /// <param name="address">Address to assign.</param>
+ public IPNetAddress(IPAddress address)
+ {
+ _address = address ?? throw new ArgumentNullException(nameof(address));
+ PrefixLength = (byte)(address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128);
+ }
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IPNetAddress"/> class.
+ /// </summary>
+ /// <param name="address">IP Address.</param>
+ /// <param name="prefixLength">Mask as a CIDR.</param>
+ public IPNetAddress(IPAddress address, byte prefixLength)
+ {
+ if (address?.IsIPv4MappedToIPv6 ?? throw new ArgumentNullException(nameof(address)))
+ {
+ _address = address.MapToIPv4();
+ }
+ else
+ {
+ _address = address;
+ }
+
+ PrefixLength = prefixLength;
+ }
+
+ /// <summary>
+ /// Gets or sets the object's IP address.
+ /// </summary>
+ public override IPAddress Address
+ {
+ get
+ {
+ return _address;
+ }
+
+ set
+ {
+ _address = value ?? IPAddress.None;
+ }
+ }
+
+ /// <inheritdoc/>
+ public override byte PrefixLength { get; set; }
+
+ /// <summary>
+ /// Try to parse the address and subnet strings into an IPNetAddress object.
+ /// </summary>
+ /// <param name="addr">IP address to parse. Can be CIDR or X.X.X.X notation.</param>
+ /// <param name="ip">Resultant object.</param>
+ /// <returns>True if the values parsed successfully. False if not, resulting in the IP being null.</returns>
+ public static bool TryParse(string addr, out IPNetAddress ip)
+ {
+ if (!string.IsNullOrEmpty(addr))
+ {
+ addr = addr.Trim();
+
+ // Try to parse it as is.
+ if (IPAddress.TryParse(addr, out IPAddress? res))
+ {
+ ip = new IPNetAddress(res);
+ return true;
+ }
+
+ // Is it a network?
+ string[] tokens = addr.Split("/");
+
+ if (tokens.Length == 2)
+ {
+ tokens[0] = tokens[0].TrimEnd();
+ tokens[1] = tokens[1].TrimStart();
+
+ if (IPAddress.TryParse(tokens[0], out res))
+ {
+ // Is the subnet part a cidr?
+ if (byte.TryParse(tokens[1], out byte cidr))
+ {
+ ip = new IPNetAddress(res, cidr);
+ return true;
+ }
+
+ // Is the subnet in x.y.a.b form?
+ if (IPAddress.TryParse(tokens[1], out IPAddress? mask))
+ {
+ ip = new IPNetAddress(res, MaskToCidr(mask));
+ return true;
+ }
+ }
+ }
+ }
+
+ ip = None;
+ return false;
+ }
+
+ /// <summary>
+ /// Parses the string provided, throwing an exception if it is badly formed.
+ /// </summary>
+ /// <param name="addr">String to parse.</param>
+ /// <returns>IPNetAddress object.</returns>
+ public static IPNetAddress Parse(string addr)
+ {
+ if (TryParse(addr, out IPNetAddress o))
+ {
+ return o;
+ }
+
+ throw new ArgumentException("Unable to recognise object :" + addr);
+ }
+
+ /// <inheritdoc/>
+ public override bool Contains(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ var altAddress = NetworkAddressOf(address, PrefixLength);
+ return NetworkAddress.Address.Equals(altAddress.Address) && NetworkAddress.PrefixLength >= altAddress.PrefixLength;
+ }
+
+ /// <inheritdoc/>
+ public override bool Contains(IPObject address)
+ {
+ if (address is IPHost addressObj && addressObj.HasAddress)
+ {
+ foreach (IPAddress addr in addressObj.GetAddresses())
+ {
+ if (Contains(addr))
+ {
+ return true;
+ }
+ }
+ }
+ else if (address is IPNetAddress netaddrObj)
+ {
+ // Have the same network address, but different subnets?
+ if (NetworkAddress.Address.Equals(netaddrObj.NetworkAddress.Address))
+ {
+ return NetworkAddress.PrefixLength <= netaddrObj.PrefixLength;
+ }
+
+ var altAddress = NetworkAddressOf(netaddrObj.Address, PrefixLength);
+ return NetworkAddress.Address.Equals(altAddress.Address);
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public override bool Equals(IPObject? other)
+ {
+ if (other is IPNetAddress otherObj && !Address.Equals(IPAddress.None) && !otherObj.Address.Equals(IPAddress.None))
+ {
+ return Address.Equals(otherObj.Address) &&
+ PrefixLength == otherObj.PrefixLength;
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public override bool Equals(IPAddress address)
+ {
+ if (address != null && !address.Equals(IPAddress.None) && !Address.Equals(IPAddress.None))
+ {
+ return address.Equals(Address);
+ }
+
+ return false;
+ }
+
+ /// <inheritdoc/>
+ public override string ToString()
+ {
+ return ToString(false);
+ }
+
+ /// <summary>
+ /// Returns a textual representation of this object.
+ /// </summary>
+ /// <param name="shortVersion">Set to true, if the subnet is to be excluded as part of the address.</param>
+ /// <returns>String representation of this object.</returns>
+ public string ToString(bool shortVersion)
+ {
+ if (!Address.Equals(IPAddress.None))
+ {
+ if (Address.Equals(IPAddress.Any))
+ {
+ return "Any IP4 Address";
+ }
+
+ if (Address.Equals(IPAddress.IPv6Any))
+ {
+ return "Any IP6 Address";
+ }
+
+ if (Address.Equals(IPAddress.Broadcast))
+ {
+ return "Any Address";
+ }
+
+ if (shortVersion)
+ {
+ return Address.ToString();
+ }
+
+ return $"{Address}/{PrefixLength}";
+ }
+
+ return string.Empty;
+ }
+
+ /// <inheritdoc/>
+ protected override IPObject CalculateNetworkAddress()
+ {
+ var value = NetworkAddressOf(_address, PrefixLength);
+ return new IPNetAddress(value.Address, value.PrefixLength);
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Net/IPObject.cs b/MediaBrowser.Common/Net/IPObject.cs
new file mode 100644
index 000000000..69cd57f8a
--- /dev/null
+++ b/MediaBrowser.Common/Net/IPObject.cs
@@ -0,0 +1,406 @@
+#nullable enable
+using System;
+using System.Net;
+using System.Net.Sockets;
+
+namespace MediaBrowser.Common.Net
+{
+ /// <summary>
+ /// Base network object class.
+ /// </summary>
+ public abstract class IPObject : IEquatable<IPObject>
+ {
+ /// <summary>
+ /// IPv6 Loopback address.
+ /// </summary>
+ protected static readonly byte[] Ipv6Loopback = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
+
+ /// <summary>
+ /// IPv4 Loopback address.
+ /// </summary>
+ protected static readonly byte[] Ipv4Loopback = { 127, 0, 0, 1 };
+
+ /// <summary>
+ /// The network address of this object.
+ /// </summary>
+ private IPObject? _networkAddress;
+
+ /// <summary>
+ /// Gets or sets a user defined value that is associated with this object.
+ /// </summary>
+ public int Tag { get; set; }
+
+ /// <summary>
+ /// Gets or sets the object's IP address.
+ /// </summary>
+ public abstract IPAddress Address { get; set; }
+
+ /// <summary>
+ /// Gets the object's network address.
+ /// </summary>
+ public IPObject NetworkAddress => _networkAddress ??= CalculateNetworkAddress();
+
+ /// <summary>
+ /// Gets or sets the object's IP address.
+ /// </summary>
+ public abstract byte PrefixLength { get; set; }
+
+ /// <summary>
+ /// Gets the AddressFamily of this object.
+ /// </summary>
+ public AddressFamily AddressFamily
+ {
+ get
+ {
+ // Keep terms separate as Address performs other functions in inherited objects.
+ IPAddress address = Address;
+ return address.Equals(IPAddress.None) ? AddressFamily.Unspecified : address.AddressFamily;
+ }
+ }
+
+ /// <summary>
+ /// Returns the network address of an object.
+ /// </summary>
+ /// <param name="address">IP Address to convert.</param>
+ /// <param name="prefixLength">Subnet prefix.</param>
+ /// <returns>IPAddress.</returns>
+ public static (IPAddress Address, byte PrefixLength) NetworkAddressOf(IPAddress address, byte prefixLength)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ if (IsLoopback(address))
+ {
+ return (Address: address, PrefixLength: prefixLength);
+ }
+
+ // An ip address is just a list of bytes, each one representing a segment on the network.
+ // This separates the IP address into octets and calculates how many octets will need to be altered or set to zero dependant upon the
+ // prefix length value. eg. /16 on a 4 octet ip4 address (192.168.2.240) will result in the 2 and the 240 being zeroed out.
+ // Where there is not an exact boundary (eg /23), mod is used to calculate how many bits of this value are to be kept.
+
+ // GetAddressBytes
+ Span<byte> addressBytes = stackalloc byte[address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+ address.TryWriteBytes(addressBytes, out _);
+
+ int div = prefixLength / 8;
+ int mod = prefixLength % 8;
+ if (mod != 0)
+ {
+ // Prefix length is counted right to left, so subtract 8 so we know how many bits to clear.
+ mod = 8 - mod;
+
+ // Shift out the bits from the octet that we don't want, by moving right then back left.
+ addressBytes[div] = (byte)((int)addressBytes[div] >> mod << mod);
+ // Move on the next byte.
+ div++;
+ }
+
+ // Blank out the remaining octets from mod + 1 to the end of the byte array. (192.168.2.240/16 becomes 192.168.0.0)
+ for (int octet = div; octet < addressBytes.Length; octet++)
+ {
+ addressBytes[octet] = 0;
+ }
+
+ // Return the network address for the prefix.
+ return (Address: new IPAddress(addressBytes), PrefixLength: prefixLength);
+ }
+
+ /// <summary>
+ /// Tests to see if the ip address is a Loopback address.
+ /// </summary>
+ /// <param name="address">Value to test.</param>
+ /// <returns>True if it is.</returns>
+ public static bool IsLoopback(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (!address.Equals(IPAddress.None))
+ {
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ return address.Equals(IPAddress.Loopback) || address.Equals(IPAddress.IPv6Loopback);
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Tests to see if the ip address is an IP6 address.
+ /// </summary>
+ /// <param name="address">Value to test.</param>
+ /// <returns>True if it is.</returns>
+ public static bool IsIP6(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ return !address.Equals(IPAddress.None) && (address.AddressFamily == AddressFamily.InterNetworkV6);
+ }
+
+ /// <summary>
+ /// Tests to see if the address in the private address range.
+ /// </summary>
+ /// <param name="address">Object to test.</param>
+ /// <returns>True if it contains a private address.</returns>
+ public static bool IsPrivateAddressRange(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (!address.Equals(IPAddress.None))
+ {
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ if (address.AddressFamily == AddressFamily.InterNetwork)
+ {
+ // GetAddressBytes
+ Span<byte> octet = stackalloc byte[4];
+ address.TryWriteBytes(octet, out _);
+
+ return (octet[0] == 10)
+ || (octet[0] == 172 && octet[1] >= 16 && octet[1] <= 31) // RFC1918
+ || (octet[0] == 192 && octet[1] == 168) // RFC1918
+ || (octet[0] == 127); // RFC1122
+ }
+ else
+ {
+ // GetAddressBytes
+ Span<byte> octet = stackalloc byte[16];
+ address.TryWriteBytes(octet, out _);
+
+ uint word = (uint)(octet[0] << 8) + octet[1];
+
+ return (word >= 0xfe80 && word <= 0xfebf) // fe80::/10 :Local link.
+ || (word >= 0xfc00 && word <= 0xfdff); // fc00::/7 :Unique local address.
+ }
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Returns true if the IPAddress contains an IP6 Local link address.
+ /// </summary>
+ /// <param name="address">IPAddress object to check.</param>
+ /// <returns>True if it is a local link address.</returns>
+ /// <remarks>
+ /// See https://stackoverflow.com/questions/6459928/explain-the-instance-properties-of-system-net-ipaddress
+ /// it appears that the IPAddress.IsIPv6LinkLocal is out of date.
+ /// </remarks>
+ public static bool IsIPv6LinkLocal(IPAddress address)
+ {
+ if (address == null)
+ {
+ throw new ArgumentNullException(nameof(address));
+ }
+
+ if (address.IsIPv4MappedToIPv6)
+ {
+ address = address.MapToIPv4();
+ }
+
+ if (address.AddressFamily != AddressFamily.InterNetworkV6)
+ {
+ return false;
+ }
+
+ // GetAddressBytes
+ Span<byte> octet = stackalloc byte[16];
+ address.TryWriteBytes(octet, out _);
+ uint word = (uint)(octet[0] << 8) + octet[1];
+
+ return word >= 0xfe80 && word <= 0xfebf; // fe80::/10 :Local link.
+ }
+
+ /// <summary>
+ /// Convert a subnet mask in CIDR notation to a dotted decimal string value. IPv4 only.
+ /// </summary>
+ /// <param name="cidr">Subnet mask in CIDR notation.</param>
+ /// <param name="family">IPv4 or IPv6 family.</param>
+ /// <returns>String value of the subnet mask in dotted decimal notation.</returns>
+ public static IPAddress CidrToMask(byte cidr, AddressFamily family)
+ {
+ uint addr = 0xFFFFFFFF << (family == AddressFamily.InterNetwork ? 32 : 128 - cidr);
+ addr = ((addr & 0xff000000) >> 24)
+ | ((addr & 0x00ff0000) >> 8)
+ | ((addr & 0x0000ff00) << 8)
+ | ((addr & 0x000000ff) << 24);
+ return new IPAddress(addr);
+ }
+
+ /// <summary>
+ /// Convert a mask to a CIDR. IPv4 only.
+ /// https://stackoverflow.com/questions/36954345/get-cidr-from-netmask.
+ /// </summary>
+ /// <param name="mask">Subnet mask.</param>
+ /// <returns>Byte CIDR representing the mask.</returns>
+ public static byte MaskToCidr(IPAddress mask)
+ {
+ if (mask == null)
+ {
+ throw new ArgumentNullException(nameof(mask));
+ }
+
+ byte cidrnet = 0;
+ if (!mask.Equals(IPAddress.Any))
+ {
+ // GetAddressBytes
+ Span<byte> bytes = stackalloc byte[mask.AddressFamily == AddressFamily.InterNetwork ? 4 : 16];
+ mask.TryWriteBytes(bytes, out _);
+
+ var zeroed = false;
+ for (var i = 0; i < bytes.Length; i++)
+ {
+ for (int v = bytes[i]; (v & 0xFF) != 0; v <<= 1)
+ {
+ if (zeroed)
+ {
+ // Invalid netmask.
+ return (byte)~cidrnet;
+ }
+
+ if ((v & 0x80) == 0)
+ {
+ zeroed = true;
+ }
+ else
+ {
+ cidrnet++;
+ }
+ }
+ }
+ }
+
+ return cidrnet;
+ }
+
+ /// <summary>
+ /// Tests to see if this object is a Loopback address.
+ /// </summary>
+ /// <returns>True if it is.</returns>
+ public virtual bool IsLoopback()
+ {
+ return IsLoopback(Address);
+ }
+
+ /// <summary>
+ /// Removes all addresses of a specific type from this object.
+ /// </summary>
+ /// <param name="family">Type of address to remove.</param>
+ public virtual void Remove(AddressFamily family)
+ {
+ // This method only performs a function in the IPHost implementation of IPObject.
+ }
+
+ /// <summary>
+ /// Tests to see if this object is an IPv6 address.
+ /// </summary>
+ /// <returns>True if it is.</returns>
+ public virtual bool IsIP6()
+ {
+ return IsIP6(Address);
+ }
+
+ /// <summary>
+ /// Returns true if this IP address is in the RFC private address range.
+ /// </summary>
+ /// <returns>True this object has a private address.</returns>
+ public virtual bool IsPrivateAddressRange()
+ {
+ return IsPrivateAddressRange(Address);
+ }
+
+ /// <summary>
+ /// Compares this to the object passed as a parameter.
+ /// </summary>
+ /// <param name="ip">Object to compare to.</param>
+ /// <returns>Equality result.</returns>
+ public virtual bool Equals(IPAddress ip)
+ {
+ if (ip != null)
+ {
+ if (ip.IsIPv4MappedToIPv6)
+ {
+ ip = ip.MapToIPv4();
+ }
+
+ return !Address.Equals(IPAddress.None) && Address.Equals(ip);
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Compares this to the object passed as a parameter.
+ /// </summary>
+ /// <param name="other">Object to compare to.</param>
+ /// <returns>Equality result.</returns>
+ public virtual bool Equals(IPObject? other)
+ {
+ if (other != null)
+ {
+ return !Address.Equals(IPAddress.None) && Address.Equals(other.Address);
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Compares the address in this object and the address in the object passed as a parameter.
+ /// </summary>
+ /// <param name="address">Object's IP address to compare to.</param>
+ /// <returns>Comparison result.</returns>
+ public abstract bool Contains(IPObject address);
+
+ /// <summary>
+ /// Compares the address in this object and the address in the object passed as a parameter.
+ /// </summary>
+ /// <param name="address">Object's IP address to compare to.</param>
+ /// <returns>Comparison result.</returns>
+ public abstract bool Contains(IPAddress address);
+
+ /// <inheritdoc/>
+ public override int GetHashCode()
+ {
+ return Address.GetHashCode();
+ }
+
+ /// <inheritdoc/>
+ public override bool Equals(object? obj)
+ {
+ return Equals(obj as IPObject);
+ }
+
+ /// <summary>
+ /// Calculates the network address of this object.
+ /// </summary>
+ /// <returns>Returns the network address of this object.</returns>
+ protected abstract IPObject CalculateNetworkAddress();
+ }
+}
diff --git a/MediaBrowser.Common/Net/NetworkExtensions.cs b/MediaBrowser.Common/Net/NetworkExtensions.cs
new file mode 100644
index 000000000..d07bba249
--- /dev/null
+++ b/MediaBrowser.Common/Net/NetworkExtensions.cs
@@ -0,0 +1,262 @@
+#pragma warning disable CA1062 // Validate arguments of public methods
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Net;
+using System.Runtime.CompilerServices;
+using System.Text;
+
+namespace MediaBrowser.Common.Net
+{
+ /// <summary>
+ /// Defines the <see cref="NetworkExtensions" />.
+ /// </summary>
+ public static class NetworkExtensions
+ {
+ /// <summary>
+ /// Add an address to the collection.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="ip">Item to add.</param>
+ public static void AddItem(this Collection<IPObject> source, IPAddress ip)
+ {
+ if (!source.ContainsAddress(ip))
+ {
+ source.Add(new IPNetAddress(ip, 32));
+ }
+ }
+
+ /// <summary>
+ /// Adds a network to the collection.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="item">Item to add.</param>
+ public static void AddItem(this Collection<IPObject> source, IPObject item)
+ {
+ if (!source.ContainsAddress(item))
+ {
+ source.Add(item);
+ }
+ }
+
+ /// <summary>
+ /// Converts this object to a string.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <returns>Returns a string representation of this object.</returns>
+ public static string AsString(this Collection<IPObject> source)
+ {
+ return $"[{string.Join(',', source)}]";
+ }
+
+ /// <summary>
+ /// Returns true if the collection contains an item with the ip address,
+ /// or the ip address falls within any of the collection's network ranges.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="item">The item to look for.</param>
+ /// <returns>True if the collection contains the item.</returns>
+ public static bool ContainsAddress(this Collection<IPObject> source, IPAddress item)
+ {
+ if (source.Count == 0)
+ {
+ return false;
+ }
+
+ if (item == null)
+ {
+ throw new ArgumentNullException(nameof(item));
+ }
+
+ if (item.IsIPv4MappedToIPv6)
+ {
+ item = item.MapToIPv4();
+ }
+
+ foreach (var i in source)
+ {
+ if (i.Contains(item))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Returns true if the collection contains an item with the ip address,
+ /// or the ip address falls within any of the collection's network ranges.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="item">The item to look for.</param>
+ /// <returns>True if the collection contains the item.</returns>
+ public static bool ContainsAddress(this Collection<IPObject> source, IPObject item)
+ {
+ if (source.Count == 0)
+ {
+ return false;
+ }
+
+ if (item == null)
+ {
+ throw new ArgumentNullException(nameof(item));
+ }
+
+ foreach (var i in source)
+ {
+ if (i.Contains(item))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// <summary>
+ /// Compares two Collection{IPObject} objects. The order is ignored.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="dest">Item to compare to.</param>
+ /// <returns>True if both are equal.</returns>
+ public static bool Compare(this Collection<IPObject> source, Collection<IPObject> dest)
+ {
+ if (dest == null || source.Count != dest.Count)
+ {
+ return false;
+ }
+
+ foreach (var sourceItem in source)
+ {
+ bool found = false;
+ foreach (var destItem in dest)
+ {
+ if (sourceItem.Equals(destItem))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /// <summary>
+ /// Returns a collection containing the subnets of this collection given.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <returns>Collection{IPObject} object containing the subnets.</returns>
+ public static Collection<IPObject> AsNetworks(this Collection<IPObject> source)
+ {
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ Collection<IPObject> res = new Collection<IPObject>();
+
+ foreach (IPObject i in source)
+ {
+ if (i is IPNetAddress nw)
+ {
+ // Add the subnet calculated from the interface address/mask.
+ var na = nw.NetworkAddress;
+ na.Tag = i.Tag;
+ res.AddItem(na);
+ }
+ else if (i is IPHost ipHost)
+ {
+ // Flatten out IPHost and add all its ip addresses.
+ foreach (var addr in ipHost.GetAddresses())
+ {
+ IPNetAddress host = new IPNetAddress(addr)
+ {
+ Tag = i.Tag
+ };
+
+ res.AddItem(host);
+ }
+ }
+ }
+
+ return res;
+ }
+
+ /// <summary>
+ /// Excludes all the items from this list that are found in excludeList.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="excludeList">Items to exclude.</param>
+ /// <returns>A new collection, with the items excluded.</returns>
+ public static Collection<IPObject> Exclude(this Collection<IPObject> source, Collection<IPObject> excludeList)
+ {
+ if (source.Count == 0 || excludeList == null)
+ {
+ return new Collection<IPObject>(source);
+ }
+
+ Collection<IPObject> results = new Collection<IPObject>();
+
+ bool found;
+ foreach (var outer in source)
+ {
+ found = false;
+
+ foreach (var inner in excludeList)
+ {
+ if (outer.Equals(inner))
+ {
+ found = true;
+ break;
+ }
+ }
+
+ if (!found)
+ {
+ results.AddItem(outer);
+ }
+ }
+
+ return results;
+ }
+
+ /// <summary>
+ /// Returns all items that co-exist in this object and target.
+ /// </summary>
+ /// <param name="source">The <see cref="Collection{IPObject}"/>.</param>
+ /// <param name="target">Collection to compare with.</param>
+ /// <returns>A collection containing all the matches.</returns>
+ public static Collection<IPObject> Union(this Collection<IPObject> source, Collection<IPObject> target)
+ {
+ if (source.Count == 0)
+ {
+ return new Collection<IPObject>();
+ }
+
+ if (target == null)
+ {
+ throw new ArgumentNullException(nameof(target));
+ }
+
+ Collection<IPObject> nc = new Collection<IPObject>();
+
+ foreach (IPObject i in source)
+ {
+ if (target.ContainsAddress(i))
+ {
+ nc.AddItem(i);
+ }
+ }
+
+ return nc;
+ }
+ }
+}
diff --git a/MediaBrowser.Common/Plugins/BasePlugin.cs b/MediaBrowser.Common/Plugins/BasePlugin.cs
index e21d8c7d1..084e91d50 100644
--- a/MediaBrowser.Common/Plugins/BasePlugin.cs
+++ b/MediaBrowser.Common/Plugins/BasePlugin.cs
@@ -247,23 +247,34 @@ namespace MediaBrowser.Common.Plugins
}
catch
{
- return (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType));
+ var config = (TConfigurationType)Activator.CreateInstance(typeof(TConfigurationType));
+ SaveConfiguration(config);
+ return config;
}
}
/// <summary>
/// Saves the current configuration to the file system.
/// </summary>
- public virtual void SaveConfiguration()
+ /// <param name="config">Configuration to save.</param>
+ public virtual void SaveConfiguration(TConfigurationType config)
{
lock (_configurationSaveLock)
{
_directoryCreateFn(Path.GetDirectoryName(ConfigurationFilePath));
- XmlSerializer.SerializeToFile(Configuration, ConfigurationFilePath);
+ XmlSerializer.SerializeToFile(config, ConfigurationFilePath);
}
}
+ /// <summary>
+ /// Saves the current configuration to the file system.
+ /// </summary>
+ public virtual void SaveConfiguration()
+ {
+ SaveConfiguration(Configuration);
+ }
+
/// <inheritdoc />
public virtual void UpdateConfiguration(BasePluginConfiguration configuration)
{
@@ -274,9 +285,9 @@ namespace MediaBrowser.Common.Plugins
Configuration = (TConfigurationType)configuration;
- SaveConfiguration();
+ SaveConfiguration(Configuration);
- ConfigurationChanged.Invoke(this, configuration);
+ ConfigurationChanged?.Invoke(this, configuration);
}
/// <inheritdoc />
diff --git a/MediaBrowser.Common/Plugins/LocalPlugin.cs b/MediaBrowser.Common/Plugins/LocalPlugin.cs
index 7927c663d..c97e75a3b 100644
--- a/MediaBrowser.Common/Plugins/LocalPlugin.cs
+++ b/MediaBrowser.Common/Plugins/LocalPlugin.cs
@@ -1,11 +1,11 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Globalization;
namespace MediaBrowser.Common.Plugins
{
/// <summary>
- /// Local plugin struct.
+ /// Local plugin class.
/// </summary>
public class LocalPlugin : IEquatable<LocalPlugin>
{
@@ -106,6 +106,12 @@ namespace MediaBrowser.Common.Plugins
/// <inheritdoc />
public bool Equals(LocalPlugin other)
{
+ // Do not use == or != for comparison as this class overrides the operators.
+ if (object.ReferenceEquals(other, null))
+ {
+ return false;
+ }
+
return Name.Equals(other.Name, StringComparison.OrdinalIgnoreCase)
&& Id.Equals(other.Id);
}
diff --git a/MediaBrowser.Common/Updates/IInstallationManager.cs b/MediaBrowser.Common/Updates/IInstallationManager.cs
index 6aa16fea7..585b1ee19 100644
--- a/MediaBrowser.Common/Updates/IInstallationManager.cs
+++ b/MediaBrowser.Common/Updates/IInstallationManager.cs
@@ -19,10 +19,11 @@ namespace MediaBrowser.Common.Updates
/// <summary>
/// Parses a plugin manifest at the supplied URL.
/// </summary>
+ /// <param name="manifestName">Name of the repository.</param>
/// <param name="manifest">The URL to query.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task{IReadOnlyList{PackageInfo}}.</returns>
- Task<IReadOnlyList<PackageInfo>> GetPackages(string manifest, CancellationToken cancellationToken = default);
+ Task<IList<PackageInfo>> GetPackages(string manifestName, string manifest, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all available packages.
@@ -37,11 +38,13 @@ namespace MediaBrowser.Common.Updates
/// <param name="availablePackages">The available packages.</param>
/// <param name="name">The name of the plugin.</param>
/// <param name="guid">The id of the plugin.</param>
+ /// <param name="specificVersion">The version of the plugin.</param>
/// <returns>All plugins matching the requirements.</returns>
IEnumerable<PackageInfo> FilterPackages(
IEnumerable<PackageInfo> availablePackages,
string name = null,
- Guid guid = default);
+ Guid guid = default,
+ Version specificVersion = null);
/// <summary>
/// Returns all compatible versions ordered from newest to oldest.
diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs
new file mode 100644
index 000000000..085f769d0
--- /dev/null
+++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Linq;
+using System.Threading;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Channels;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+
+namespace MediaBrowser.Controller.BaseItemManager
+{
+ /// <inheritdoc />
+ public class BaseItemManager : IBaseItemManager
+ {
+ private readonly IServerConfigurationManager _serverConfigurationManager;
+
+ private int _metadataRefreshConcurrency = 0;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BaseItemManager"/> class.
+ /// </summary>
+ /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+ public BaseItemManager(IServerConfigurationManager serverConfigurationManager)
+ {
+ _serverConfigurationManager = serverConfigurationManager;
+
+ _metadataRefreshConcurrency = GetMetadataRefreshConcurrency();
+ SetupMetadataThrottler();
+
+ _serverConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
+ }
+
+ /// <inheritdoc />
+ public SemaphoreSlim MetadataRefreshThrottler { get; private set; }
+
+ /// <inheritdoc />
+ public bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name)
+ {
+ if (baseItem is Channel)
+ {
+ // Hack alert.
+ return true;
+ }
+
+ if (baseItem.SourceType == SourceType.Channel)
+ {
+ // Hack alert.
+ return !baseItem.EnableMediaSourceDisplay;
+ }
+
+ var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
+ if (typeOptions != null)
+ {
+ return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+ }
+
+ if (!libraryOptions.EnableInternetProviders)
+ {
+ return false;
+ }
+
+ var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase));
+
+ return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+ }
+
+ /// <inheritdoc />
+ public bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name)
+ {
+ if (baseItem is Channel)
+ {
+ // Hack alert.
+ return true;
+ }
+
+ if (baseItem.SourceType == SourceType.Channel)
+ {
+ // Hack alert.
+ return !baseItem.EnableMediaSourceDisplay;
+ }
+
+ var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
+ if (typeOptions != null)
+ {
+ return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+ }
+
+ if (!libraryOptions.EnableInternetProviders)
+ {
+ return false;
+ }
+
+ var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase));
+
+ return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
+ }
+
+ /// <summary>
+ /// Called when the configuration is updated.
+ /// It will refresh the metadata throttler if the relevant config changed.
+ /// </summary>
+ private void OnConfigurationUpdated(object sender, EventArgs e)
+ {
+ int newMetadataRefreshConcurrency = GetMetadataRefreshConcurrency();
+ if (_metadataRefreshConcurrency != newMetadataRefreshConcurrency)
+ {
+ _metadataRefreshConcurrency = newMetadataRefreshConcurrency;
+ SetupMetadataThrottler();
+ }
+ }
+
+ /// <summary>
+ /// Creates the metadata refresh throttler.
+ /// </summary>
+ private void SetupMetadataThrottler()
+ {
+ MetadataRefreshThrottler = new SemaphoreSlim(_metadataRefreshConcurrency);
+ }
+
+ /// <summary>
+ /// Returns the metadata refresh concurrency.
+ /// </summary>
+ private int GetMetadataRefreshConcurrency()
+ {
+ var concurrency = _serverConfigurationManager.Configuration.LibraryMetadataRefreshConcurrency;
+
+ if (concurrency <= 0)
+ {
+ concurrency = Environment.ProcessorCount;
+ }
+
+ return concurrency;
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs
new file mode 100644
index 000000000..e1f5d05a6
--- /dev/null
+++ b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+
+namespace MediaBrowser.Controller.BaseItemManager
+{
+ /// <summary>
+ /// The <c>BaseItem</c> manager.
+ /// </summary>
+ public interface IBaseItemManager
+ {
+ /// <summary>
+ /// Gets the semaphore used to limit the amount of concurrent metadata refreshes.
+ /// </summary>
+ SemaphoreSlim MetadataRefreshThrottler { get; }
+
+ /// <summary>
+ /// Is metadata fetcher enabled.
+ /// </summary>
+ /// <param name="baseItem">The base item.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <param name="name">The metadata fetcher name.</param>
+ /// <returns><c>true</c> if metadata fetcher is enabled, else false.</returns>
+ bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name);
+
+ /// <summary>
+ /// Is image fetcher enabled.
+ /// </summary>
+ /// <param name="baseItem">The base item.</param>
+ /// <param name="libraryOptions">The library options.</param>
+ /// <param name="name">The image fetcher name.</param>
+ /// <returns><c>true</c> if image fetcher is enabled, else false.</returns>
+ bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name);
+ }
+} \ No newline at end of file
diff --git a/MediaBrowser.Controller/Channels/IChannelManager.cs b/MediaBrowser.Controller/Channels/IChannelManager.cs
index 9a9d22d33..ddae7dbd3 100644
--- a/MediaBrowser.Controller/Channels/IChannelManager.cs
+++ b/MediaBrowser.Controller/Channels/IChannelManager.cs
@@ -24,7 +24,7 @@ namespace MediaBrowser.Controller.Channels
/// </summary>
/// <param name="id">The identifier.</param>
/// <returns>ChannelFeatures.</returns>
- ChannelFeatures GetChannelFeatures(string id);
+ ChannelFeatures GetChannelFeatures(Guid? id);
/// <summary>
/// Gets all channel features.
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 1d44a5511..d8fad3bfb 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -463,60 +463,6 @@ namespace MediaBrowser.Controller.Entities
[JsonIgnore]
public string PrimaryImagePath => this.GetImagePath(ImageType.Primary);
- public bool IsMetadataFetcherEnabled(LibraryOptions libraryOptions, string name)
- {
- if (SourceType == SourceType.Channel)
- {
- // hack alert
- return !EnableMediaSourceDisplay;
- }
-
- var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
- if (typeOptions != null)
- {
- return typeOptions.MetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
- }
-
- if (!libraryOptions.EnableInternetProviders)
- {
- return false;
- }
-
- var itemConfig = ConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase));
-
- return itemConfig == null || !itemConfig.DisabledMetadataFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
- }
-
- public bool IsImageFetcherEnabled(LibraryOptions libraryOptions, string name)
- {
- if (this is Channel)
- {
- // hack alert
- return true;
- }
-
- if (SourceType == SourceType.Channel)
- {
- // hack alert
- return !EnableMediaSourceDisplay;
- }
-
- var typeOptions = libraryOptions.GetTypeOptions(GetType().Name);
- if (typeOptions != null)
- {
- return typeOptions.ImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
- }
-
- if (!libraryOptions.EnableInternetProviders)
- {
- return false;
- }
-
- var itemConfig = ConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, GetType().Name, StringComparison.OrdinalIgnoreCase));
-
- return itemConfig == null || !itemConfig.DisabledImageFetchers.Contains(name, StringComparer.OrdinalIgnoreCase);
- }
-
public virtual bool CanDelete()
{
if (SourceType == SourceType.Channel)
@@ -1439,7 +1385,6 @@ namespace MediaBrowser.Controller.Entities
new List<FileSystemMetadata>();
var ownedItemsChanged = await RefreshedOwnedItems(options, files, cancellationToken).ConfigureAwait(false);
- await LibraryManager.UpdateImagesAsync(this).ConfigureAwait(false); // ensure all image properties in DB are fresh
if (ownedItemsChanged)
{
@@ -2611,7 +2556,7 @@ namespace MediaBrowser.Controller.Entities
{
if (!AllowsMultipleImages(type))
{
- throw new ArgumentException("The change index operation is only applicable to backdrops and screenshots");
+ throw new ArgumentException("The change index operation is only applicable to backdrops and screen shots");
}
var info1 = GetImageInfo(type, index1);
diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs
index a76c8a376..23f4c00c1 100644
--- a/MediaBrowser.Controller/Entities/Folder.cs
+++ b/MediaBrowser.Controller/Entities/Folder.cs
@@ -8,6 +8,7 @@ using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
+using System.Threading.Tasks.Dataflow;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Progress;
@@ -212,7 +213,7 @@ namespace MediaBrowser.Controller.Entities
/// <summary>
/// Loads our children. Validation will occur externally.
- /// We want this sychronous.
+ /// We want this synchronous.
/// </summary>
protected virtual List<BaseItem> LoadChildren()
{
@@ -328,11 +329,11 @@ namespace MediaBrowser.Controller.Entities
return;
}
- progress.Report(5);
+ progress.Report(ProgressHelpers.RetrievedChildren);
if (recursive)
{
- ProviderManager.OnRefreshProgress(this, 5);
+ ProviderManager.OnRefreshProgress(this, ProgressHelpers.RetrievedChildren);
}
// Build a dictionary of the current children we have now by Id so we can compare quickly and easily
@@ -353,11 +354,6 @@ namespace MediaBrowser.Controller.Entities
{
await currentChild.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
}
- else
- {
- // metadata is up-to-date; make sure DB has correct images dimensions and hash
- await LibraryManager.UpdateImagesAsync(currentChild).ConfigureAwait(false);
- }
continue;
}
@@ -393,11 +389,11 @@ namespace MediaBrowser.Controller.Entities
validChildrenNeedGeneration = true;
}
- progress.Report(10);
+ progress.Report(ProgressHelpers.UpdatedChildItems);
if (recursive)
{
- ProviderManager.OnRefreshProgress(this, 10);
+ ProviderManager.OnRefreshProgress(this, ProgressHelpers.UpdatedChildItems);
}
cancellationToken.ThrowIfCancellationRequested();
@@ -407,11 +403,13 @@ namespace MediaBrowser.Controller.Entities
var innerProgress = new ActionableProgress<double>();
var folder = this;
- innerProgress.RegisterAction(p =>
+ innerProgress.RegisterAction(innerPercent =>
{
- double newPct = 0.80 * p + 10;
- progress.Report(newPct);
- ProviderManager.OnRefreshProgress(folder, newPct);
+ var percent = ProgressHelpers.GetProgress(ProgressHelpers.UpdatedChildItems, ProgressHelpers.ScannedSubfolders, innerPercent);
+
+ progress.Report(percent);
+
+ ProviderManager.OnRefreshProgress(folder, percent);
});
if (validChildrenNeedGeneration)
@@ -425,11 +423,11 @@ namespace MediaBrowser.Controller.Entities
if (refreshChildMetadata)
{
- progress.Report(90);
+ progress.Report(ProgressHelpers.ScannedSubfolders);
if (recursive)
{
- ProviderManager.OnRefreshProgress(this, 90);
+ ProviderManager.OnRefreshProgress(this, ProgressHelpers.ScannedSubfolders);
}
var container = this as IMetadataContainer;
@@ -437,13 +435,15 @@ namespace MediaBrowser.Controller.Entities
var innerProgress = new ActionableProgress<double>();
var folder = this;
- innerProgress.RegisterAction(p =>
+ innerProgress.RegisterAction(innerPercent =>
{
- double newPct = 0.10 * p + 90;
- progress.Report(newPct);
+ var percent = ProgressHelpers.GetProgress(ProgressHelpers.ScannedSubfolders, ProgressHelpers.RefreshedMetadata, innerPercent);
+
+ progress.Report(percent);
+
if (recursive)
{
- ProviderManager.OnRefreshProgress(folder, newPct);
+ ProviderManager.OnRefreshProgress(folder, percent);
}
});
@@ -458,55 +458,35 @@ namespace MediaBrowser.Controller.Entities
validChildren = Children.ToList();
}
- await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken);
+ await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
}
}
}
- private async Task RefreshMetadataRecursive(List<BaseItem> children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken)
+ private Task RefreshMetadataRecursive(IList<BaseItem> children, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken)
{
- var numComplete = 0;
- var count = children.Count;
- double currentPercent = 0;
-
- foreach (var child in children)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- var innerProgress = new ActionableProgress<double>();
-
- // Avoid implicitly captured closure
- var currentInnerPercent = currentPercent;
-
- innerProgress.RegisterAction(p =>
- {
- double innerPercent = currentInnerPercent;
- innerPercent += p / count;
- progress.Report(innerPercent);
- });
-
- await RefreshChildMetadata(child, refreshOptions, recursive && child.IsFolder, innerProgress, cancellationToken)
- .ConfigureAwait(false);
-
- numComplete++;
- double percent = numComplete;
- percent /= count;
- percent *= 100;
- currentPercent = percent;
-
- progress.Report(percent);
- }
+ return RunTasks(
+ (baseItem, innerProgress) => RefreshChildMetadata(baseItem, refreshOptions, recursive && baseItem.IsFolder, innerProgress, cancellationToken),
+ children,
+ progress,
+ cancellationToken);
}
private async Task RefreshAllMetadataForContainer(IMetadataContainer container, MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
{
- var series = container as Series;
- if (series != null)
- {
- await series.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false);
- }
+ // limit the amount of concurrent metadata refreshes
+ await ProviderManager.RunMetadataRefresh(
+ async () =>
+ {
+ var series = container as Series;
+ if (series != null)
+ {
+ await series.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false);
+ }
- await container.RefreshAllMetadata(refreshOptions, progress, cancellationToken).ConfigureAwait(false);
+ await container.RefreshAllMetadata(refreshOptions, progress, cancellationToken).ConfigureAwait(false);
+ },
+ cancellationToken).ConfigureAwait(false);
}
private async Task RefreshChildMetadata(BaseItem child, MetadataRefreshOptions refreshOptions, bool recursive, IProgress<double> progress, CancellationToken cancellationToken)
@@ -521,12 +501,15 @@ namespace MediaBrowser.Controller.Entities
{
if (refreshOptions.RefreshItem(child))
{
- await child.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false);
+ // limit the amount of concurrent metadata refreshes
+ await ProviderManager.RunMetadataRefresh(
+ async () => await child.RefreshMetadata(refreshOptions, cancellationToken).ConfigureAwait(false),
+ cancellationToken).ConfigureAwait(false);
}
if (recursive && child is Folder folder)
{
- await folder.RefreshMetadataRecursive(folder.Children.ToList(), refreshOptions, true, progress, cancellationToken);
+ await folder.RefreshMetadataRecursive(folder.Children.ToList(), refreshOptions, true, progress, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -539,39 +522,72 @@ namespace MediaBrowser.Controller.Entities
/// <param name="progress">The progress.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- private async Task ValidateSubFolders(IList<Folder> children, IDirectoryService directoryService, IProgress<double> progress, CancellationToken cancellationToken)
+ private Task ValidateSubFolders(IList<Folder> children, IDirectoryService directoryService, IProgress<double> progress, CancellationToken cancellationToken)
{
- var numComplete = 0;
- var count = children.Count;
- double currentPercent = 0;
+ return RunTasks(
+ (folder, innerProgress) => folder.ValidateChildrenInternal(innerProgress, cancellationToken, true, false, null, directoryService),
+ children,
+ progress,
+ cancellationToken);
+ }
- foreach (var child in children)
+ /// <summary>
+ /// Runs an action block on a list of children.
+ /// </summary>
+ /// <param name="task">The task to run for each child.</param>
+ /// <param name="children">The list of children.</param>
+ /// <param name="progress">The progress.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>Task.</returns>
+ private async Task RunTasks<T>(Func<T, IProgress<double>, Task> task, IList<T> children, IProgress<double> progress, CancellationToken cancellationToken)
+ {
+ var childrenCount = children.Count;
+ var childrenProgress = new double[childrenCount];
+
+ void UpdateProgress()
{
- cancellationToken.ThrowIfCancellationRequested();
+ progress.Report(childrenProgress.Average());
+ }
- var innerProgress = new ActionableProgress<double>();
+ var fanoutConcurrency = ConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
+ var parallelism = fanoutConcurrency == 0 ? Environment.ProcessorCount : fanoutConcurrency;
- // Avoid implicitly captured closure
- var currentInnerPercent = currentPercent;
+ var actionBlock = new ActionBlock<int>(
+ async i =>
+ {
+ var innerProgress = new ActionableProgress<double>();
- innerProgress.RegisterAction(p =>
+ innerProgress.RegisterAction(innerPercent =>
+ {
+ // round the percent and only update progress if it changed to prevent excessive UpdateProgress calls
+ var innerPercentRounded = Math.Round(innerPercent);
+ if (childrenProgress[i] != innerPercentRounded)
+ {
+ childrenProgress[i] = innerPercentRounded;
+ UpdateProgress();
+ }
+ });
+
+ await task(children[i], innerProgress).ConfigureAwait(false);
+
+ childrenProgress[i] = 100;
+
+ UpdateProgress();
+ },
+ new ExecutionDataflowBlockOptions
{
- double innerPercent = currentInnerPercent;
- innerPercent += p / count;
- progress.Report(innerPercent);
+ MaxDegreeOfParallelism = parallelism,
+ CancellationToken = cancellationToken,
});
- await child.ValidateChildrenInternal(innerProgress, cancellationToken, true, false, null, directoryService)
- .ConfigureAwait(false);
+ for (var i = 0; i < childrenCount; i++)
+ {
+ actionBlock.Post(i);
+ }
- numComplete++;
- double percent = numComplete;
- percent /= count;
- percent *= 100;
- currentPercent = percent;
+ actionBlock.Complete();
- progress.Report(percent);
- }
+ await actionBlock.Completion.ConfigureAwait(false);
}
/// <summary>
@@ -1067,12 +1083,12 @@ namespace MediaBrowser.Controller.Entities
return false;
}
- if (request.Genres.Length > 0)
+ if (request.Genres.Count > 0)
{
return false;
}
- if (request.GenreIds.Length > 0)
+ if (request.GenreIds.Count > 0)
{
return false;
}
@@ -1177,7 +1193,7 @@ namespace MediaBrowser.Controller.Entities
return false;
}
- if (request.GenreIds.Length > 0)
+ if (request.GenreIds.Count > 0)
{
return false;
}
@@ -1768,5 +1784,45 @@ namespace MediaBrowser.Controller.Entities
}
}
}
+
+ /// <summary>
+ /// Contains constants used when reporting scan progress.
+ /// </summary>
+ private static class ProgressHelpers
+ {
+ /// <summary>
+ /// Reported after the folders immediate children are retrieved.
+ /// </summary>
+ public const int RetrievedChildren = 5;
+
+ /// <summary>
+ /// Reported after add, updating, or deleting child items from the LibraryManager.
+ /// </summary>
+ public const int UpdatedChildItems = 10;
+
+ /// <summary>
+ /// Reported once subfolders are scanned.
+ /// When scanning subfolders, the progress will be between [UpdatedItems, ScannedSubfolders].
+ /// </summary>
+ public const int ScannedSubfolders = 50;
+
+ /// <summary>
+ /// Reported once metadata is refreshed.
+ /// When refreshing metadata, the progress will be between [ScannedSubfolders, MetadataRefreshed].
+ /// </summary>
+ public const int RefreshedMetadata = 100;
+
+ /// <summary>
+ /// Gets the current progress given the previous step, next step, and progress in between.
+ /// </summary>
+ /// <param name="previousProgressStep">The previous progress step.</param>
+ /// <param name="nextProgressStep">The next progress step.</param>
+ /// <param name="currentProgress">The current progress step.</param>
+ /// <returns>The progress.</returns>
+ public static double GetProgress(int previousProgressStep, int nextProgressStep, double currentProgress)
+ {
+ return previousProgressStep + ((nextProgressStep - previousProgressStep) * (currentProgress / 100));
+ }
+ }
}
}
diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
index 904752a22..270217356 100644
--- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
+++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs
@@ -46,7 +46,7 @@ namespace MediaBrowser.Controller.Entities
public string[] ExcludeInheritedTags { get; set; }
- public string[] Genres { get; set; }
+ public IReadOnlyList<string> Genres { get; set; }
public bool? IsSpecialSeason { get; set; }
@@ -116,7 +116,7 @@ namespace MediaBrowser.Controller.Entities
public Guid[] StudioIds { get; set; }
- public Guid[] GenreIds { get; set; }
+ public IReadOnlyList<Guid> GenreIds { get; set; }
public ImageType[] ImageTypes { get; set; }
@@ -162,7 +162,7 @@ namespace MediaBrowser.Controller.Entities
public double? MinCommunityRating { get; set; }
- public Guid[] ChannelIds { get; set; }
+ public IReadOnlyList<Guid> ChannelIds { get; set; }
public int? ParentIndexNumber { get; set; }
diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
index a262fee15..4e33a6bbd 100644
--- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs
+++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs
@@ -791,7 +791,7 @@ namespace MediaBrowser.Controller.Entities
}
// Apply genre filter
- if (query.Genres.Length > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase)))
+ if (query.Genres.Count > 0 && !query.Genres.Any(v => item.Genres.Contains(v, StringComparer.OrdinalIgnoreCase)))
{
return false;
}
@@ -822,7 +822,7 @@ namespace MediaBrowser.Controller.Entities
}
// Apply genre filter
- if (query.GenreIds.Length > 0 && !query.GenreIds.Any(id =>
+ if (query.GenreIds.Count > 0 && !query.GenreIds.Any(id =>
{
var genreItem = libraryManager.GetItemById(id);
return genreItem != null && item.Genres.Contains(genreItem.Name, StringComparer.OrdinalIgnoreCase);
diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs
index 07f381881..6320b01b8 100644
--- a/MediaBrowser.Controller/Entities/Video.cs
+++ b/MediaBrowser.Controller/Entities/Video.cs
@@ -143,26 +143,6 @@ namespace MediaBrowser.Controller.Entities
/// <value>The video3 D format.</value>
public Video3DFormat? Video3DFormat { get; set; }
- public string[] GetPlayableStreamFileNames()
- {
- var videoType = VideoType;
-
- if (videoType == VideoType.Iso && IsoType == Model.Entities.IsoType.BluRay)
- {
- videoType = VideoType.BluRay;
- }
- else if (videoType == VideoType.Iso && IsoType == Model.Entities.IsoType.Dvd)
- {
- videoType = VideoType.Dvd;
- }
- else
- {
- return Array.Empty<string>();
- }
-
- throw new NotImplementedException();
- }
-
/// <summary>
/// Gets or sets the aspect ratio.
/// </summary>
@@ -415,31 +395,6 @@ namespace MediaBrowser.Controller.Entities
return updateType;
}
- public static string[] QueryPlayableStreamFiles(string rootPath, VideoType videoType)
- {
- if (videoType == VideoType.Dvd)
- {
- return FileSystem.GetFiles(rootPath, new[] { ".vob" }, false, true)
- .OrderByDescending(i => i.Length)
- .ThenBy(i => i.FullName)
- .Take(1)
- .Select(i => i.FullName)
- .ToArray();
- }
-
- if (videoType == VideoType.BluRay)
- {
- return FileSystem.GetFiles(rootPath, new[] { ".m2ts" }, false, true)
- .OrderByDescending(i => i.Length)
- .ThenBy(i => i.FullName)
- .Take(1)
- .Select(i => i.FullName)
- .ToArray();
- }
-
- return Array.Empty<string>();
- }
-
/// <summary>
/// Gets a value indicating whether [is3 D].
/// </summary>
diff --git a/MediaBrowser.Controller/IDisplayPreferencesManager.cs b/MediaBrowser.Controller/IDisplayPreferencesManager.cs
index 6658269bd..041eeea62 100644
--- a/MediaBrowser.Controller/IDisplayPreferencesManager.cs
+++ b/MediaBrowser.Controller/IDisplayPreferencesManager.cs
@@ -16,9 +16,10 @@ namespace MediaBrowser.Controller
/// This will create the display preferences if it does not exist, but it will not save automatically.
/// </remarks>
/// <param name="userId">The user's id.</param>
+ /// <param name="itemId">The item id.</param>
/// <param name="client">The client string.</param>
/// <returns>The associated display preferences.</returns>
- DisplayPreferences GetDisplayPreferences(Guid userId, string client);
+ DisplayPreferences GetDisplayPreferences(Guid userId, Guid itemId, string client);
/// <summary>
/// Gets the default item display preferences for the user and client.
@@ -41,6 +42,24 @@ namespace MediaBrowser.Controller
IList<ItemDisplayPreferences> ListItemDisplayPreferences(Guid userId, string client);
/// <summary>
+ /// Gets all of the custom item display preferences for the user and client.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="client">The client string.</param>
+ /// <returns>The dictionary of custom item display preferences.</returns>
+ IDictionary<string, string> ListCustomItemDisplayPreferences(Guid userId, Guid itemId, string client);
+
+ /// <summary>
+ /// Sets the custom item display preference for the user and client.
+ /// </summary>
+ /// <param name="userId">The user id.</param>
+ /// <param name="itemId">The item id.</param>
+ /// <param name="client">The client id.</param>
+ /// <param name="customPreferences">A dictionary of custom item display preferences.</param>
+ void SetCustomItemDisplayPreferences(Guid userId, Guid itemId, string client, Dictionary<string, string> customPreferences);
+
+ /// <summary>
/// Saves changes made to the database.
/// </summary>
void SaveChanges();
diff --git a/MediaBrowser.Controller/IResourceFileManager.cs b/MediaBrowser.Controller/IResourceFileManager.cs
deleted file mode 100644
index 26f0424b7..000000000
--- a/MediaBrowser.Controller/IResourceFileManager.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-#pragma warning disable CS1591
-
-namespace MediaBrowser.Controller
-{
- public interface IResourceFileManager
- {
- string GetResourcePath(string basePath, string virtualPath);
- }
-}
diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs
index ffbb147b0..2456da826 100644
--- a/MediaBrowser.Controller/IServerApplicationHost.cs
+++ b/MediaBrowser.Controller/IServerApplicationHost.cs
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using MediaBrowser.Common;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.System;
+using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller
{
@@ -56,41 +57,42 @@ namespace MediaBrowser.Controller
/// <summary>
/// Gets the system info.
/// </summary>
- /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param>
+ /// <param name="source">The originator of the request.</param>
/// <returns>SystemInfo.</returns>
- Task<SystemInfo> GetSystemInfo(CancellationToken cancellationToken = default);
+ SystemInfo GetSystemInfo(IPAddress source);
- Task<PublicSystemInfo> GetPublicSystemInfo(CancellationToken cancellationToken = default);
+ PublicSystemInfo GetPublicSystemInfo(IPAddress address);
/// <summary>
- /// Gets all the local IP addresses of this API instance. Each address is validated by sending a 'ping' request
- /// to the API that should exist at the address.
+ /// Gets a URL specific for the request.
/// </summary>
- /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param>
- /// <returns>A list containing all the local IP addresses of the server.</returns>
- Task<List<IPAddress>> GetLocalIpAddresses(CancellationToken cancellationToken = default);
+ /// <param name="request">The <see cref="HttpRequest"/> instance.</param>
+ /// <param name="port">Optional port number.</param>
+ /// <returns>An accessible URL.</returns>
+ string GetSmartApiUrl(HttpRequest request, int? port = null);
/// <summary>
- /// Gets a local (LAN) URL that can be used to access the API. The hostname used is the first valid configured
- /// IP address that can be found via <see cref="GetLocalIpAddresses"/>. HTTPS will be preferred when available.
+ /// Gets a URL specific for the request.
/// </summary>
- /// <param name="cancellationToken">A cancellation token that can be used to cancel the task.</param>
- /// <returns>The server URL.</returns>
- Task<string> GetLocalApiUrl(CancellationToken cancellationToken = default);
+ /// <param name="remoteAddr">The remote <see cref="IPAddress"/> of the connection.</param>
+ /// <param name="port">Optional port number.</param>
+ /// <returns>An accessible URL.</returns>
+ string GetSmartApiUrl(IPAddress remoteAddr, int? port = null);
/// <summary>
- /// Gets a localhost URL that can be used to access the API using the loop-back IP address (127.0.0.1)
- /// over HTTP (not HTTPS).
+ /// Gets a URL specific for the request.
/// </summary>
- /// <returns>The API URL.</returns>
- string GetLoopbackHttpApiUrl();
+ /// <param name="hostname">The hostname used in the connection.</param>
+ /// <param name="port">Optional port number.</param>
+ /// <returns>An accessible URL.</returns>
+ string GetSmartApiUrl(string hostname, int? port = null);
/// <summary>
- /// Gets a local (LAN) URL that can be used to access the API. HTTPS will be preferred when available.
+ /// Gets a localhost URL that can be used to access the API using the loop-back IP address.
+ /// over HTTP (not HTTPS).
/// </summary>
- /// <param name="address">The IP address to use as the hostname in the URL.</param>
/// <returns>The API URL.</returns>
- string GetLocalApiUrl(IPAddress address);
+ string GetLoopbackHttpApiUrl();
/// <summary>
/// Gets a local (LAN) URL that can be used to access the API.
@@ -106,7 +108,7 @@ namespace MediaBrowser.Controller
/// preferring the HTTPS port, if available.
/// </param>
/// <returns>The API URL.</returns>
- string GetLocalApiUrl(ReadOnlySpan<char> hostname, string scheme = null, int? port = null);
+ string GetLocalApiUrl(string hostname, string scheme = null, int? port = null);
/// <summary>
/// Open a URL in an external browser window.
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index c7c79df76..24b101694 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -571,6 +571,10 @@ namespace MediaBrowser.Controller.Library
string videoPath,
string[] files);
+ void RunMetadataSavers(IReadOnlyList<BaseItem> items, ItemUpdateType updateReason);
+
BaseItem GetParentItem(string parentId, Guid? userId);
+
+ BaseItem GetParentItem(Guid? parentId, Guid? userId);
}
}
diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
index 9acc98dce..5f75df54e 100644
--- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj
+++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
@@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="5.0.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0" PrivateAssets="All"/>
+ <PackageReference Include="System.Threading.Tasks.Dataflow" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index 5846a603a..91a03e647 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -7,6 +7,7 @@ using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
+using System.Text.RegularExpressions;
using System.Threading;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
@@ -23,7 +24,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
public class EncodingHelper
{
- private readonly CultureInfo _usCulture = new CultureInfo("en-US");
+ private static readonly CultureInfo _usCulture = new CultureInfo("en-US");
private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem;
@@ -63,7 +64,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{
// Only use alternative encoders for video files.
// When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully
- // Since transcoding of folder rips is expiremental anyway, it's not worth adding additional variables such as this.
+ // Since transcoding of folder rips is experimental anyway, it's not worth adding additional variables such as this.
if (state.VideoType == VideoType.VideoFile)
{
var hwType = encodingOptions.HardwareAccelerationType;
@@ -111,6 +112,16 @@ namespace MediaBrowser.Controller.MediaEncoding
return _mediaEncoder.SupportsHwaccel("vaapi");
}
+ private bool IsTonemappingSupported(EncodingJobInfo state, EncodingOptions options)
+ {
+ var videoStream = state.VideoStream;
+ return IsColorDepth10(state)
+ && _mediaEncoder.SupportsHwaccel("opencl")
+ && options.EnableTonemapping
+ && !string.IsNullOrEmpty(videoStream.VideoRange)
+ && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase);
+ }
+
/// <summary>
/// Gets the name of the output video codec.
/// </summary>
@@ -247,7 +258,7 @@ namespace MediaBrowser.Controller.MediaEncoding
return null;
}
- // Seeing reported failures here, not sure yet if this is related to specfying input format
+ // Seeing reported failures here, not sure yet if this is related to specifying input format
if (string.Equals(container, "m4v", StringComparison.OrdinalIgnoreCase))
{
return null;
@@ -379,25 +390,9 @@ namespace MediaBrowser.Controller.MediaEncoding
public string GetInputPathArgument(EncodingJobInfo state)
{
- var protocol = state.InputProtocol;
var mediaPath = state.MediaPath ?? string.Empty;
- string[] inputPath;
- if (state.IsInputVideo
- && !(state.VideoType == VideoType.Iso && state.IsoMount == null))
- {
- inputPath = MediaEncoderHelpers.GetInputArgument(
- _fileSystem,
- mediaPath,
- state.IsoMount,
- state.PlayableStreamFileNames);
- }
- else
- {
- inputPath = new[] { mediaPath };
- }
-
- return _mediaEncoder.GetInputArgument(inputPath, protocol);
+ return _mediaEncoder.GetInputArgument(mediaPath, state.MediaSource);
}
/// <summary>
@@ -440,6 +435,12 @@ namespace MediaBrowser.Controller.MediaEncoding
return "libopus";
}
+ if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase))
+ {
+ // flac is experimental in mp4 muxer
+ return "flac -strict -2";
+ }
+
return codec.ToLowerInvariant();
}
@@ -461,6 +462,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
var isMacOS = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
+ var isTonemappingSupported = IsTonemappingSupported(state, encodingOptions);
if (!IsCopyCodec(outputVideoCodec))
{
@@ -470,10 +472,23 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (isVaapiDecoder)
{
- arg.Append("-hwaccel_output_format vaapi ")
- .Append("-vaapi_device ")
- .Append(encodingOptions.VaapiDevice)
- .Append(' ');
+ if (isTonemappingSupported)
+ {
+ arg.Append("-init_hw_device vaapi=va:")
+ .Append(encodingOptions.VaapiDevice)
+ .Append(' ')
+ .Append("-init_hw_device opencl=ocl@va ")
+ .Append("-hwaccel_device va ")
+ .Append("-hwaccel_output_format vaapi ")
+ .Append("-filter_hw_device ocl ");
+ }
+ else
+ {
+ arg.Append("-hwaccel_output_format vaapi ")
+ .Append("-vaapi_device ")
+ .Append(encodingOptions.VaapiDevice)
+ .Append(' ');
+ }
}
else if (!isVaapiDecoder && isVaapiEncoder)
{
@@ -522,13 +537,7 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (string.Equals(encodingOptions.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && isNvdecHevcDecoder || isSwDecoder)
|| (string.Equals(encodingOptions.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && isD3d11vaDecoder || isSwDecoder))
{
- var isColorDepth10 = IsColorDepth10(state);
-
- if (isColorDepth10
- && _mediaEncoder.SupportsHwaccel("opencl")
- && encodingOptions.EnableTonemapping
- && !string.IsNullOrEmpty(state.VideoStream.VideoRange)
- && state.VideoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
+ if (isTonemappingSupported)
{
arg.Append("-init_hw_device opencl=ocl:")
.Append(encodingOptions.OpenclDevice)
@@ -573,7 +582,7 @@ namespace MediaBrowser.Controller.MediaEncoding
/// </summary>
/// <param name="stream">The stream.</param>
/// <returns><c>true</c> if the specified stream is H264; otherwise, <c>false</c>.</returns>
- public bool IsH264(MediaStream stream)
+ public static bool IsH264(MediaStream stream)
{
var codec = stream.Codec ?? string.Empty;
@@ -581,7 +590,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|| codec.IndexOf("avc", StringComparison.OrdinalIgnoreCase) != -1;
}
- public bool IsH265(MediaStream stream)
+ public static bool IsH265(MediaStream stream)
{
var codec = stream.Codec ?? string.Empty;
@@ -589,10 +598,17 @@ namespace MediaBrowser.Controller.MediaEncoding
|| codec.IndexOf("hevc", StringComparison.OrdinalIgnoreCase) != -1;
}
- // TODO This is auto inserted into the mpegts mux so it might not be needed
- // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb
- public string GetBitStreamArgs(MediaStream stream)
+ public static bool IsAAC(MediaStream stream)
{
+ var codec = stream.Codec ?? string.Empty;
+
+ return codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1;
+ }
+
+ public static string GetBitStreamArgs(MediaStream stream)
+ {
+ // TODO This is auto inserted into the mpegts mux so it might not be needed.
+ // https://www.ffmpeg.org/ffmpeg-bitstream-filters.html#h264_005fmp4toannexb
if (IsH264(stream))
{
return "-bsf:v h264_mp4toannexb";
@@ -601,12 +617,44 @@ namespace MediaBrowser.Controller.MediaEncoding
{
return "-bsf:v hevc_mp4toannexb";
}
+ else if (IsAAC(stream))
+ {
+ // Convert adts header(mpegts) to asc header(mp4).
+ return "-bsf:a aac_adtstoasc";
+ }
else
{
return null;
}
}
+ public static string GetAudioBitStreamArguments(EncodingJobInfo state, string segmentContainer, string mediaSourceContainer)
+ {
+ var bitStreamArgs = string.Empty;
+ var segmentFormat = GetSegmentFileExtension(segmentContainer).TrimStart('.');
+
+ // Apply aac_adtstoasc bitstream filter when media source is in mpegts.
+ if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)
+ && (string.Equals(mediaSourceContainer, "mpegts", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)))
+ {
+ bitStreamArgs = GetBitStreamArgs(state.AudioStream);
+ bitStreamArgs = string.IsNullOrEmpty(bitStreamArgs) ? string.Empty : " " + bitStreamArgs;
+ }
+
+ return bitStreamArgs;
+ }
+
+ public static string GetSegmentFileExtension(string segmentContainer)
+ {
+ if (!string.IsNullOrWhiteSpace(segmentContainer))
+ {
+ return "." + segmentContainer;
+ }
+
+ return ".ts";
+ }
+
public string GetVideoBitrateParam(EncodingJobInfo state, string videoCodec)
{
var bitrate = state.OutputVideoBitrate;
@@ -654,16 +702,30 @@ namespace MediaBrowser.Controller.MediaEncoding
return string.Empty;
}
- public string NormalizeTranscodingLevel(string videoCodec, string level)
+ public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
{
- // Clients may direct play higher than level 41, but there's no reason to transcode higher
- if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel)
- && requestLevel > 41
- && (string.Equals(videoCodec, "h264", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoCodec, "h265", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoCodec, "hevc", StringComparison.OrdinalIgnoreCase)))
+ if (double.TryParse(level, NumberStyles.Any, _usCulture, out double requestLevel))
{
- return "41";
+ if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase))
+ {
+ // Transcode to level 5.0 and lower for maximum compatibility.
+ // Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it.
+ // https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
+ // MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880.
+ if (requestLevel >= 150)
+ {
+ return "150";
+ }
+ }
+ else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
+ {
+ // Clients may direct play higher than level 41, but there's no reason to transcode higher.
+ if (requestLevel >= 41)
+ {
+ return "41";
+ }
+ }
}
return level;
@@ -766,6 +828,72 @@ namespace MediaBrowser.Controller.MediaEncoding
return null;
}
+ public string GetHlsVideoKeyFrameArguments(
+ EncodingJobInfo state,
+ string codec,
+ int segmentLength,
+ bool isEventPlaylist,
+ int? startNumber)
+ {
+ var args = string.Empty;
+ var gopArg = string.Empty;
+ var keyFrameArg = string.Empty;
+ if (isEventPlaylist)
+ {
+ keyFrameArg = string.Format(
+ CultureInfo.InvariantCulture,
+ " -force_key_frames:0 \"expr:gte(t,n_forced*{0})\"",
+ segmentLength);
+ }
+ else if (startNumber.HasValue)
+ {
+ keyFrameArg = string.Format(
+ CultureInfo.InvariantCulture,
+ " -force_key_frames:0 \"expr:gte(t,{0}+n_forced*{1})\"",
+ startNumber.Value * segmentLength,
+ segmentLength);
+ }
+
+ var framerate = state.VideoStream?.RealFrameRate;
+ if (framerate.HasValue)
+ {
+ // This is to make sure keyframe interval is limited to our segment,
+ // as forcing keyframes is not enough.
+ // Example: we encoded half of desired length, then codec detected
+ // scene cut and inserted a keyframe; next forced keyframe would
+ // be created outside of segment, which breaks seeking.
+ // -sc_threshold 0 is used to prevent the hardware encoder from post processing to break the set keyframe.
+ gopArg = string.Format(
+ CultureInfo.InvariantCulture,
+ " -g:v:0 {0} -keyint_min:v:0 {0} -sc_threshold:v:0 0",
+ Math.Ceiling(segmentLength * framerate.Value));
+ }
+
+ // Unable to force key frames using these encoders, set key frames by GOP.
+ if (string.Equals(codec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "h264_amf", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ args += gopArg;
+ }
+ else if (string.Equals(codec, "libx264", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
+ {
+ args += " " + keyFrameArg;
+ }
+ else
+ {
+ args += " " + keyFrameArg + gopArg;
+ }
+
+ return args;
+ }
+
/// <summary>
/// Gets the video bitrate to specify on the command line.
/// </summary>
@@ -773,6 +901,47 @@ namespace MediaBrowser.Controller.MediaEncoding
{
var param = string.Empty;
+ if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -pix_fmt yuv420p";
+ }
+
+ if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ var videoStream = state.VideoStream;
+ var isColorDepth10 = IsColorDepth10(state);
+
+ if (isColorDepth10
+ && _mediaEncoder.SupportsHwaccel("opencl")
+ && encodingOptions.EnableTonemapping
+ && !string.IsNullOrEmpty(videoStream.VideoRange)
+ && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -pix_fmt nv12";
+ }
+ else
+ {
+ param += " -pix_fmt yuv420p";
+ }
+ }
+
+ if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -pix_fmt nv21";
+ }
+
var isVc1 = state.VideoStream != null &&
string.Equals(state.VideoStream.Codec, "vc1", StringComparison.OrdinalIgnoreCase);
var isLibX265 = string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase);
@@ -781,11 +950,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{
if (!string.IsNullOrEmpty(encodingOptions.EncoderPreset))
{
- param += "-preset " + encodingOptions.EncoderPreset;
+ param += " -preset " + encodingOptions.EncoderPreset;
}
else
{
- param += "-preset " + defaultPreset;
+ param += " -preset " + defaultPreset;
}
int encodeCrf = encodingOptions.H264Crf;
@@ -809,38 +978,40 @@ namespace MediaBrowser.Controller.MediaEncoding
param += " -crf " + defaultCrf;
}
}
- else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) // h264 (h264_qsv)
+ else if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) // h264 (h264_qsv)
+ || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_qsv)
{
string[] valid_h264_qsv = { "veryslow", "slower", "slow", "medium", "fast", "faster", "veryfast" };
if (valid_h264_qsv.Contains(encodingOptions.EncoderPreset, StringComparer.OrdinalIgnoreCase))
{
- param += "-preset " + encodingOptions.EncoderPreset;
+ param += " -preset " + encodingOptions.EncoderPreset;
}
else
{
- param += "-preset 7";
+ param += " -preset 7";
}
param += " -look_ahead 0";
}
else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc)
- || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_nvenc)
{
+ // following preset will be deprecated in ffmpeg 4.4, use p1~p7 instead.
switch (encodingOptions.EncoderPreset)
{
case "veryslow":
- param += "-preset slow"; // lossless is only supported on maxwell and newer(2014+)
+ param += " -preset slow"; // lossless is only supported on maxwell and newer(2014+)
break;
case "slow":
case "slower":
- param += "-preset slow";
+ param += " -preset slow";
break;
case "medium":
- param += "-preset medium";
+ param += " -preset medium";
break;
case "fast":
@@ -848,27 +1019,27 @@ namespace MediaBrowser.Controller.MediaEncoding
case "veryfast":
case "superfast":
case "ultrafast":
- param += "-preset fast";
+ param += " -preset fast";
break;
default:
- param += "-preset default";
+ param += " -preset default";
break;
}
}
- else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase) // h264 (h264_amf)
+ || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_amf)
{
switch (encodingOptions.EncoderPreset)
{
case "veryslow":
case "slow":
case "slower":
- param += "-quality quality";
+ param += " -quality quality";
break;
case "medium":
- param += "-quality balanced";
+ param += " -quality balanced";
break;
case "fast":
@@ -876,11 +1047,11 @@ namespace MediaBrowser.Controller.MediaEncoding
case "veryfast":
case "superfast":
case "ultrafast":
- param += "-quality speed";
+ param += " -quality speed";
break;
default:
- param += "-quality speed";
+ param += " -quality speed";
break;
}
@@ -896,6 +1067,11 @@ namespace MediaBrowser.Controller.MediaEncoding
// Enhance workload when tone mapping with AMF on some APUs
param += " -preanalysis true";
}
+
+ if (string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -header_insertion_mode gop -gops_per_idr 1";
+ }
}
else if (string.Equals(videoEncoder, "libvpx", StringComparison.OrdinalIgnoreCase)) // webm
{
@@ -917,7 +1093,7 @@ namespace MediaBrowser.Controller.MediaEncoding
profileScore = Math.Min(profileScore, 2);
// http://www.webmproject.org/docs/encoder-parameters/
- param += string.Format(CultureInfo.InvariantCulture, "-speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}",
+ param += string.Format(CultureInfo.InvariantCulture, " -speed 16 -quality good -profile:v {0} -slices 8 -crf {1} -qmin {2} -qmax {3}",
profileScore.ToString(_usCulture),
crf,
qmin,
@@ -925,15 +1101,15 @@ namespace MediaBrowser.Controller.MediaEncoding
}
else if (string.Equals(videoEncoder, "mpeg4", StringComparison.OrdinalIgnoreCase))
{
- param += "-mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2";
+ param += " -mbd rd -flags +mv4+aic -trellis 2 -cmp 2 -subcmp 2 -bf 2";
}
else if (string.Equals(videoEncoder, "wmv2", StringComparison.OrdinalIgnoreCase)) // asf/wmv
{
- param += "-qmin 2";
+ param += " -qmin 2";
}
else if (string.Equals(videoEncoder, "msmpeg4", StringComparison.OrdinalIgnoreCase))
{
- param += "-mbd 2";
+ param += " -mbd 2";
}
param += GetVideoBitrateParam(state, videoEncoder);
@@ -945,11 +1121,25 @@ namespace MediaBrowser.Controller.MediaEncoding
}
var targetVideoCodec = state.ActualOutputVideoCodec;
+ if (string.Equals(targetVideoCodec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(targetVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
+ {
+ targetVideoCodec = "hevc";
+ }
var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault();
+ profile = Regex.Replace(profile, @"\s+", String.Empty);
- // vaapi does not support Baseline profile, force Constrained Baseline in this case,
- // which is compatible (and ugly)
+ // Only libx264 support encoding H264 High 10 Profile, otherwise force High Profile.
+ if (!string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
+ && profile != null
+ && profile.IndexOf("high 10", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ profile = "high";
+ }
+
+ // h264_vaapi does not support Baseline profile, force Constrained Baseline in this case,
+ // which is compatible (and ugly).
if (string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
&& profile != null
&& profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
@@ -957,13 +1147,31 @@ namespace MediaBrowser.Controller.MediaEncoding
profile = "constrained_baseline";
}
+ // libx264, h264_qsv and h264_nvenc does not support Constrained Baseline profile, force Baseline in this case.
+ if ((string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase))
+ && profile != null
+ && profile.IndexOf("baseline", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ profile = "baseline";
+ }
+
+ // Currently hevc_amf only support encoding HEVC Main Profile, otherwise force Main Profile.
+ if (!string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase)
+ && profile != null
+ && profile.IndexOf("main 10", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ profile = "main";
+ }
+
if (!string.IsNullOrEmpty(profile))
{
if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
{
// not supported by h264_omx
- param += " -profile:v " + profile;
+ param += " -profile:v:0 " + profile;
}
}
@@ -971,55 +1179,35 @@ namespace MediaBrowser.Controller.MediaEncoding
if (!string.IsNullOrEmpty(level))
{
- level = NormalizeTranscodingLevel(state.OutputVideoCodec, level);
+ level = NormalizeTranscodingLevel(state, level);
- // h264_qsv and h264_nvenc expect levels to be expressed as a decimal. libx264 supports decimal and non-decimal format
- // also needed for libx264 due to https://trac.ffmpeg.org/ticket/3307
+ // libx264, QSV, AMF, VAAPI can adjust the given level to match the output.
if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -level " + level;
+ }
+ else if (string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
{
- switch (level)
+ // hevc_qsv use -level 51 instead of -level 153.
+ if (double.TryParse(level, NumberStyles.Any, _usCulture, out double hevcLevel))
{
- case "30":
- param += " -level 3.0";
- break;
- case "31":
- param += " -level 3.1";
- break;
- case "32":
- param += " -level 3.2";
- break;
- case "40":
- param += " -level 4.0";
- break;
- case "41":
- param += " -level 4.1";
- break;
- case "42":
- param += " -level 4.2";
- break;
- case "50":
- param += " -level 5.0";
- break;
- case "51":
- param += " -level 5.1";
- break;
- case "52":
- param += " -level 5.2";
- break;
- default:
- param += " -level " + level;
- break;
+ param += " -level " + hevcLevel / 3;
}
}
+ else if (string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_amf", StringComparison.OrdinalIgnoreCase))
+ {
+ param += " -level " + level;
+ }
else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase))
{
- // nvenc doesn't decode with param -level set ?!
- // TODO:
+ // level option may cause NVENC to fail.
+ // NVENC cannot adjust the given level, just throw an error.
}
- else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase))
+ else if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
+ || !string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
{
param += " -level " + level;
}
@@ -1032,42 +1220,11 @@ namespace MediaBrowser.Controller.MediaEncoding
if (string.Equals(videoEncoder, "libx265", StringComparison.OrdinalIgnoreCase))
{
- // todo
- }
-
- if (!string.Equals(videoEncoder, "h264_omx", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
- {
- param = "-pix_fmt yuv420p " + param;
- }
-
- if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "h264_amf", StringComparison.OrdinalIgnoreCase))
- {
- var videoStream = state.VideoStream;
- var isColorDepth10 = IsColorDepth10(state);
-
- if (isColorDepth10
- && _mediaEncoder.SupportsHwaccel("opencl")
- && encodingOptions.EnableTonemapping
- && !string.IsNullOrEmpty(videoStream.VideoRange)
- && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
- {
- param = "-pix_fmt nv12 " + param;
- }
- else
- {
- param = "-pix_fmt yuv420p " + param;
- }
- }
-
- if (string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))
- {
- param = "-pix_fmt nv21 " + param;
+ // libx265 only accept level option in -x265-params.
+ // level option may cause libx265 to fail.
+ // libx265 cannot adjust the given level, just throw an error.
+ // TODO: set fine tuned params.
+ param += " -x265-params:0 no-info=1";
}
return param;
@@ -1346,7 +1503,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|| string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
{
- return .5;
+ return .6;
}
return 1;
@@ -1380,36 +1537,48 @@ namespace MediaBrowser.Controller.MediaEncoding
public int? GetAudioBitrateParam(BaseEncodingJobOptions request, MediaStream audioStream)
{
- if (audioStream == null)
- {
- return null;
- }
-
- if (request.AudioBitRate.HasValue)
- {
- // Don't encode any higher than this
- return Math.Min(384000, request.AudioBitRate.Value);
- }
-
- // Empty bitrate area is not allow on iOS
- // Default audio bitrate to 128K if it is not being requested
- // https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
- return 128000;
+ return GetAudioBitrateParam(request.AudioBitRate, request.AudioCodec, audioStream);
}
- public int? GetAudioBitrateParam(int? audioBitRate, MediaStream audioStream)
+ public int? GetAudioBitrateParam(int? audioBitRate, string audioCodec, MediaStream audioStream)
{
if (audioStream == null)
{
return null;
}
- if (audioBitRate.HasValue)
+ if (audioBitRate.HasValue && string.IsNullOrEmpty(audioCodec))
{
- // Don't encode any higher than this
return Math.Min(384000, audioBitRate.Value);
}
+ if (audioBitRate.HasValue && !string.IsNullOrEmpty(audioCodec))
+ {
+ if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
+ {
+ if ((audioStream.Channels ?? 0) >= 6)
+ {
+ return Math.Min(640000, audioBitRate.Value);
+ }
+
+ return Math.Min(384000, audioBitRate.Value);
+ }
+
+ if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase))
+ {
+ if ((audioStream.Channels ?? 0) >= 6)
+ {
+ return Math.Min(3584000, audioBitRate.Value);
+ }
+
+ return Math.Min(1536000, audioBitRate.Value);
+ }
+ }
+
// Empty bitrate area is not allow on iOS
// Default audio bitrate to 128K if it is not being requested
// https://ffmpeg.org/ffmpeg-codecs.html#toc-Codec-Options
@@ -1447,7 +1616,7 @@ namespace MediaBrowser.Controller.MediaEncoding
if (filters.Count > 0)
{
- return "-af \"" + string.Join(",", filters) + "\"";
+ return " -af \"" + string.Join(",", filters) + "\"";
}
return string.Empty;
@@ -1462,6 +1631,11 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <returns>System.Nullable{System.Int32}.</returns>
public int? GetNumAudioChannelsParam(EncodingJobInfo state, MediaStream audioStream, string outputAudioCodec)
{
+ if (audioStream == null)
+ {
+ return null;
+ }
+
var request = state.BaseRequest;
var inputChannels = audioStream?.Channels;
@@ -1484,6 +1658,11 @@ namespace MediaBrowser.Controller.MediaEncoding
// libmp3lame currently only supports two channel output
transcoderChannelLimit = 2;
}
+ else if (codec.IndexOf("aac", StringComparison.OrdinalIgnoreCase) != -1)
+ {
+ // aac is able to handle 8ch(7.1 layout)
+ transcoderChannelLimit = 8;
+ }
else
{
// If we don't have any media info then limit it to 6 to prevent encoding errors due to asking for too many channels
@@ -1689,6 +1868,19 @@ namespace MediaBrowser.Controller.MediaEncoding
var videoDecoder = GetHardwareAcceleratedVideoDecoder(state, options) ?? string.Empty;
var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
+ var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1;
+ var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
+ var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
+ var isTonemappingSupported = IsTonemappingSupported(state, options);
+ var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder);
+
+ // Tonemapping and burn-in graphical subtitles requires overlay_vaapi.
+ // But it's still in ffmpeg mailing list. Disable it for now.
+ if (isTonemappingSupported && isTonemappingSupportedOnVaapi)
+ {
+ return GetOutputSizeParam(state, options, outputVideoCodec);
+ }
+
// Setup subtitle scaling
if (state.VideoStream != null && state.VideoStream.Width.HasValue && state.VideoStream.Height.HasValue)
{
@@ -1708,7 +1900,8 @@ namespace MediaBrowser.Controller.MediaEncoding
}
// For QSV, feed it into hardware encoder now
- if (isLinux && string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
+ if (isLinux && (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase)))
{
videoSizeParam += ",hwupload=extra_hw_frames=64";
}
@@ -1729,7 +1922,8 @@ namespace MediaBrowser.Controller.MediaEncoding
: " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}][sub]overlay\"";
// When the input may or may not be hardware VAAPI decodable
- if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(outputVideoCodec, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(outputVideoCodec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase))
{
/*
[base]: HW scaling video to OutputSize
@@ -1741,7 +1935,8 @@ namespace MediaBrowser.Controller.MediaEncoding
// If we're hardware VAAPI decoding and software encoding, download frames from the decoder first
else if (_mediaEncoder.SupportsHwaccel("vaapi") && videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1
- && string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase))
+ && (string.Equals(outputVideoCodec, "libx264", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(outputVideoCodec, "libx265", StringComparison.OrdinalIgnoreCase)))
{
/*
[base]: SW scaling video to OutputSize
@@ -1750,7 +1945,8 @@ namespace MediaBrowser.Controller.MediaEncoding
*/
retStr = " -filter_complex \"[{0}:{1}]{4}[sub];[0:{2}]{3}[base];[base][sub]overlay\"";
}
- else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
+ else if (string.Equals(outputVideoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(outputVideoCodec, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
{
/*
QSV in FFMpeg can now setup hardware overlay for transcodes.
@@ -1776,7 +1972,7 @@ namespace MediaBrowser.Controller.MediaEncoding
videoSizeParam);
}
- private (int? width, int? height) GetFixedOutputSize(
+ public static (int? width, int? height) GetFixedOutputSize(
int? videoWidth,
int? videoHeight,
int? requestedWidth,
@@ -1816,6 +2012,7 @@ namespace MediaBrowser.Controller.MediaEncoding
public List<string> GetScalingFilters(
EncodingJobInfo state,
+ EncodingOptions options,
int? videoWidth,
int? videoHeight,
Video3DFormat? threedFormat,
@@ -1836,7 +2033,9 @@ namespace MediaBrowser.Controller.MediaEncoding
requestedMaxHeight);
if ((string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase)
- || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase))
+ || string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase))
&& width.HasValue
&& height.HasValue)
{
@@ -1845,12 +2044,26 @@ namespace MediaBrowser.Controller.MediaEncoding
// output dimensions. Output dimensions are guaranteed to be even.
var outputWidth = width.Value;
var outputHeight = height.Value;
- var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase);
+ var qsv_or_vaapi = string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(videoEncoder, "hevc_qsv", StringComparison.OrdinalIgnoreCase);
var isDeintEnabled = state.DeInterlace("h264", true)
|| state.DeInterlace("avc", true)
|| state.DeInterlace("h265", true)
|| state.DeInterlace("hevc", true);
+ var isTonemappingSupported = IsTonemappingSupported(state, options);
+ var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && !qsv_or_vaapi;
+
+ var outputPixFmt = string.Empty;
+ if (isTonemappingSupported && isTonemappingSupportedOnVaapi)
+ {
+ outputPixFmt = "format=p010:out_range=limited";
+ }
+ else
+ {
+ outputPixFmt = "format=nv12";
+ }
+
if (!videoWidth.HasValue
|| outputWidth != videoWidth.Value
|| !videoHeight.HasValue
@@ -1861,10 +2074,11 @@ namespace MediaBrowser.Controller.MediaEncoding
filters.Add(
string.Format(
CultureInfo.InvariantCulture,
- "{0}=w={1}:h={2}:format=nv12{3}",
+ "{0}=w={1}:h={2}{3}{4}",
qsv_or_vaapi ? "vpp_qsv" : "scale_vaapi",
outputWidth,
outputHeight,
+ ":" + outputPixFmt,
(qsv_or_vaapi && isDeintEnabled) ? ":deinterlace=1" : string.Empty));
}
else
@@ -1872,8 +2086,9 @@ namespace MediaBrowser.Controller.MediaEncoding
filters.Add(
string.Format(
CultureInfo.InvariantCulture,
- "{0}=format=nv12{1}",
+ "{0}={1}{2}",
qsv_or_vaapi ? "vpp_qsv" : "scale_vaapi",
+ outputPixFmt,
(qsv_or_vaapi && isDeintEnabled) ? ":deinterlace=1" : string.Empty));
}
}
@@ -2106,13 +2321,21 @@ namespace MediaBrowser.Controller.MediaEncoding
var isSwDecoder = string.IsNullOrEmpty(videoDecoder);
var isD3d11vaDecoder = videoDecoder.IndexOf("d3d11va", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiDecoder = videoDecoder.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1;
+ var isVaapiEncoder = outputVideoCodec.IndexOf("vaapi", StringComparison.OrdinalIgnoreCase) != -1;
var isVaapiH264Encoder = outputVideoCodec.IndexOf("h264_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
+ var isVaapiHevcEncoder = outputVideoCodec.IndexOf("hevc_vaapi", StringComparison.OrdinalIgnoreCase) != -1;
var isQsvH264Encoder = outputVideoCodec.IndexOf("h264_qsv", StringComparison.OrdinalIgnoreCase) != -1;
+ var isQsvHevcEncoder = outputVideoCodec.IndexOf("hevc_qsv", StringComparison.OrdinalIgnoreCase) != -1;
var isNvdecH264Decoder = videoDecoder.IndexOf("h264_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
var isNvdecHevcDecoder = videoDecoder.IndexOf("hevc_cuvid", StringComparison.OrdinalIgnoreCase) != -1;
var isLibX264Encoder = outputVideoCodec.IndexOf("libx264", StringComparison.OrdinalIgnoreCase) != -1;
+ var isLibX265Encoder = outputVideoCodec.IndexOf("libx265", StringComparison.OrdinalIgnoreCase) != -1;
var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
var isColorDepth10 = IsColorDepth10(state);
+ var isTonemappingSupported = IsTonemappingSupported(state, options);
+ var isTonemappingSupportedOnNvenc = string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && isNvdecHevcDecoder || isSwDecoder;
+ var isTonemappingSupportedOnAmf = string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && isD3d11vaDecoder || isSwDecoder;
+ var isTonemappingSupportedOnVaapi = string.Equals(options.HardwareAccelerationType, "vaapi", StringComparison.OrdinalIgnoreCase) && isVaapiDecoder && (isVaapiH264Encoder || isVaapiHevcEncoder);
var hasTextSubs = state.SubtitleStream != null && state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
var hasGraphicalSubs = state.SubtitleStream != null && !state.SubtitleStream.IsTextSubtitleStream && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode;
@@ -2124,18 +2347,14 @@ namespace MediaBrowser.Controller.MediaEncoding
var isDeinterlaceH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var isDeinterlaceHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
- if ((string.Equals(options.HardwareAccelerationType, "nvenc", StringComparison.OrdinalIgnoreCase) && isNvdecHevcDecoder || isSwDecoder)
- || (string.Equals(options.HardwareAccelerationType, "amf", StringComparison.OrdinalIgnoreCase) && isD3d11vaDecoder || isSwDecoder))
+ if (isTonemappingSupportedOnNvenc || isTonemappingSupportedOnAmf || isTonemappingSupportedOnVaapi)
{
// Currently only with the use of NVENC decoder can we get a decent performance.
// Currently only the HEVC/H265 format is supported with NVDEC decoder.
// NVIDIA Pascal and Turing or higher are recommended.
// AMD Polaris and Vega or higher are recommended.
- if (isColorDepth10
- && _mediaEncoder.SupportsHwaccel("opencl")
- && options.EnableTonemapping
- && !string.IsNullOrEmpty(videoStream.VideoRange)
- && videoStream.VideoRange.Contains("HDR", StringComparison.OrdinalIgnoreCase))
+ // Intel Kaby Lake or newer is required.
+ if (isTonemappingSupported)
{
var parameters = "tonemap_opencl=format=nv12:primaries=bt709:transfer=bt709:matrix=bt709:tonemap={0}:desat={1}:threshold={2}:peak={3}";
@@ -2168,10 +2387,35 @@ namespace MediaBrowser.Controller.MediaEncoding
filters.Add("format=p010");
}
- // Upload the HDR10 or HLG data to the OpenCL device,
- // use tonemap_opencl filter for tone mapping,
- // and then download the SDR data to memory.
- filters.Add("hwupload");
+ if (isNvdecHevcDecoder || isSwDecoder || isD3d11vaDecoder)
+ {
+ // Upload the HDR10 or HLG data to the OpenCL device,
+ // use tonemap_opencl filter for tone mapping,
+ // and then download the SDR data to memory.
+ filters.Add("hwupload");
+ }
+
+ if (isVaapiDecoder)
+ {
+ isScalingInAdvance = true;
+ filters.AddRange(
+ GetScalingFilters(
+ state,
+ options,
+ inputWidth,
+ inputHeight,
+ threeDFormat,
+ videoDecoder,
+ outputVideoCodec,
+ request.Width,
+ request.Height,
+ request.MaxWidth,
+ request.MaxHeight));
+
+ // hwmap the HDR data to opencl device by cl-va p010 interop.
+ filters.Add("hwmap");
+ }
+
filters.Add(
string.Format(
CultureInfo.InvariantCulture,
@@ -2182,33 +2426,47 @@ namespace MediaBrowser.Controller.MediaEncoding
options.TonemappingPeak,
options.TonemappingParam,
options.TonemappingRange));
- filters.Add("hwdownload");
- if (isLibX264Encoder
- || hasGraphicalSubs
- || (isNvdecHevcDecoder && isDeinterlaceHevc)
- || (!isNvdecHevcDecoder && isDeinterlaceH264 || isDeinterlaceHevc))
+ if (isNvdecHevcDecoder || isSwDecoder || isD3d11vaDecoder)
+ {
+ filters.Add("hwdownload");
+ }
+
+ if (isSwDecoder || isD3d11vaDecoder)
+ {
+ if (isLibX264Encoder
+ || isLibX265Encoder
+ || hasGraphicalSubs
+ || (isNvdecHevcDecoder && isDeinterlaceHevc)
+ || (!isNvdecHevcDecoder && isDeinterlaceH264 || isDeinterlaceHevc))
+ {
+ filters.Add("format=nv12");
+ }
+ }
+
+ if (isVaapiDecoder)
{
- filters.Add("format=nv12");
+ // Reverse the data route from opencl to vaapi.
+ filters.Add("hwmap=derive_device=vaapi:reverse=1");
}
}
}
- // When the input may or may not be hardware VAAPI decodable
- if (isVaapiH264Encoder)
+ // When the input may or may not be hardware VAAPI decodable.
+ if ((isVaapiH264Encoder || isVaapiHevcEncoder) && !isTonemappingSupported && !isTonemappingSupportedOnVaapi)
{
filters.Add("format=nv12|vaapi");
filters.Add("hwupload");
}
- // When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context
- else if (isLinux && hasGraphicalSubs && isQsvH264Encoder)
+ // When burning in graphical subtitles using overlay_qsv, upload videostream to the same qsv context.
+ else if (isLinux && hasGraphicalSubs && (isQsvH264Encoder || isQsvHevcEncoder))
{
filters.Add("hwupload=extra_hw_frames=64");
}
- // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first
- else if (IsVaapiSupported(state) && isVaapiDecoder && isLibX264Encoder)
+ // If we're hardware VAAPI decoding and software encoding, download frames from the decoder first.
+ else if (IsVaapiSupported(state) && isVaapiDecoder && (isLibX264Encoder || isLibX265Encoder))
{
var codec = videoStream.Codec.ToLowerInvariant();
@@ -2234,7 +2492,7 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
- // Add hardware deinterlace filter before scaling filter
+ // Add hardware deinterlace filter before scaling filter.
if (isDeinterlaceH264)
{
if (isVaapiH264Encoder)
@@ -2247,10 +2505,12 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
- // Add software deinterlace filter before scaling filter
+ // Add software deinterlace filter before scaling filter.
if ((isDeinterlaceH264 || isDeinterlaceHevc)
&& !isVaapiH264Encoder
+ && !isVaapiHevcEncoder
&& !isQsvH264Encoder
+ && !isQsvHevcEncoder
&& !isNvdecH264Decoder)
{
if (string.Equals(options.DeinterlaceMethod, "bwdif", StringComparison.OrdinalIgnoreCase))
@@ -2277,6 +2537,7 @@ namespace MediaBrowser.Controller.MediaEncoding
filters.AddRange(
GetScalingFilters(
state,
+ options,
inputWidth,
inputHeight,
threeDFormat,
@@ -2289,10 +2550,17 @@ namespace MediaBrowser.Controller.MediaEncoding
}
// Add parameters to use VAAPI with burn-in text subtitles (GH issue #642)
- if (isVaapiH264Encoder)
+ if (isVaapiH264Encoder || isVaapiHevcEncoder)
{
if (hasTextSubs)
{
+ // Convert hw context from ocl to va.
+ // For tonemapping and text subs burn-in.
+ if (isTonemappingSupported && isTonemappingSupportedOnVaapi)
+ {
+ filters.Add("scale_vaapi");
+ }
+
// Test passed on Intel and AMD gfx
filters.Add("hwmap=mode=read+write");
filters.Add("format=nv12");
@@ -2329,7 +2597,8 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <summary>
/// Gets the number of threads.
/// </summary>
- public int GetNumberOfThreads(EncodingJobInfo state, EncodingOptions encodingOptions, string outputVideoCodec)
+#nullable enable
+ public static int GetNumberOfThreads(EncodingJobInfo? state, EncodingOptions encodingOptions, string? outputVideoCodec)
{
if (string.Equals(outputVideoCodec, "libvpx", StringComparison.OrdinalIgnoreCase))
{
@@ -2339,17 +2608,21 @@ namespace MediaBrowser.Controller.MediaEncoding
return Math.Max(Environment.ProcessorCount - 1, 1);
}
- var threads = state.BaseRequest.CpuCoreLimit ?? encodingOptions.EncodingThreadCount;
+ var threads = state?.BaseRequest.CpuCoreLimit ?? encodingOptions.EncodingThreadCount;
// Automatic
- if (threads <= 0 || threads >= Environment.ProcessorCount)
+ if (threads <= 0)
{
return 0;
+ }
+ else if (threads >= Environment.ProcessorCount)
+ {
+ return Environment.ProcessorCount;
}
return threads;
}
-
+#nullable disable
public void TryStreamCopy(EncodingJobInfo state)
{
if (state.VideoStream != null && CanStreamCopyVideo(state, state.VideoStream))
@@ -2384,18 +2657,10 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
- public string GetProbeSizeArgument(int numInputFiles)
- => numInputFiles > 1 ? "-probesize " + _configuration.GetFFmpegProbeSize() : string.Empty;
-
- public string GetAnalyzeDurationArgument(int numInputFiles)
- => numInputFiles > 1 ? "-analyzeduration " + _configuration.GetFFmpegAnalyzeDuration() : string.Empty;
-
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions)
{
var inputModifier = string.Empty;
-
- var numInputFiles = state.PlayableStreamFileNames.Length > 0 ? state.PlayableStreamFileNames.Length : 1;
- var probeSizeArgument = GetProbeSizeArgument(numInputFiles);
+ var probeSizeArgument = string.Empty;
string analyzeDurationArgument;
if (state.MediaSource.AnalyzeDurationMs.HasValue)
@@ -2404,7 +2669,7 @@ namespace MediaBrowser.Controller.MediaEncoding
}
else
{
- analyzeDurationArgument = GetAnalyzeDurationArgument(numInputFiles);
+ analyzeDurationArgument = string.Empty;
}
if (!string.IsNullOrEmpty(probeSizeArgument))
@@ -2557,6 +2822,7 @@ namespace MediaBrowser.Controller.MediaEncoding
public void AttachMediaSourceInfo(
EncodingJobInfo state,
+ EncodingOptions encodingOptions,
MediaSourceInfo mediaSource,
string requestedUrl)
{
@@ -2587,32 +2853,6 @@ namespace MediaBrowser.Controller.MediaEncoding
state.IsoType = mediaSource.IsoType;
- if (mediaSource.VideoType.HasValue)
- {
- state.VideoType = mediaSource.VideoType.Value;
-
- if (mediaSource.VideoType.Value == VideoType.BluRay || mediaSource.VideoType.Value == VideoType.Dvd)
- {
- state.PlayableStreamFileNames = Video.QueryPlayableStreamFiles(state.MediaPath, mediaSource.VideoType.Value).Select(Path.GetFileName).ToArray();
- }
- else if (mediaSource.VideoType.Value == VideoType.Iso && state.IsoType == IsoType.BluRay)
- {
- state.PlayableStreamFileNames = Video.QueryPlayableStreamFiles(state.MediaPath, VideoType.BluRay).Select(Path.GetFileName).ToArray();
- }
- else if (mediaSource.VideoType.Value == VideoType.Iso && state.IsoType == IsoType.Dvd)
- {
- state.PlayableStreamFileNames = Video.QueryPlayableStreamFiles(state.MediaPath, VideoType.Dvd).Select(Path.GetFileName).ToArray();
- }
- else
- {
- state.PlayableStreamFileNames = Array.Empty<string>();
- }
- }
- else
- {
- state.PlayableStreamFileNames = Array.Empty<string>();
- }
-
if (mediaSource.Timestamp.HasValue)
{
state.InputTimestamp = mediaSource.Timestamp.Value;
@@ -2687,11 +2927,23 @@ namespace MediaBrowser.Controller.MediaEncoding
request.AudioCodec = state.SupportedAudioCodecs.FirstOrDefault(i => _mediaEncoder.CanEncodeToAudioCodec(i))
?? state.SupportedAudioCodecs.FirstOrDefault();
}
+
+ var supportedVideoCodecs = state.SupportedVideoCodecs;
+ if (request != null && supportedVideoCodecs != null && supportedVideoCodecs.Length > 0)
+ {
+ var supportedVideoCodecsList = supportedVideoCodecs.ToList();
+
+ ShiftVideoCodecsIfNeeded(supportedVideoCodecsList, encodingOptions);
+
+ state.SupportedVideoCodecs = supportedVideoCodecsList.ToArray();
+
+ request.VideoCodec = state.SupportedVideoCodecs.FirstOrDefault();
+ }
}
private void ShiftAudioCodecsIfNeeded(List<string> audioCodecs, MediaStream audioStream)
{
- // Nothing to do here
+ // No need to shift if there is only one supported audio codec.
if (audioCodecs.Count < 2)
{
return;
@@ -2719,6 +2971,34 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
+ private void ShiftVideoCodecsIfNeeded(List<string> videoCodecs, EncodingOptions encodingOptions)
+ {
+ // Shift hevc/h265 to the end of list if hevc encoding is not allowed.
+ if (encodingOptions.AllowHevcEncoding)
+ {
+ return;
+ }
+
+ // No need to shift if there is only one supported video codec.
+ if (videoCodecs.Count < 2)
+ {
+ return;
+ }
+
+ var shiftVideoCodecs = new[] { "hevc", "h265" };
+ if (videoCodecs.All(i => shiftVideoCodecs.Contains(i, StringComparer.OrdinalIgnoreCase)))
+ {
+ return;
+ }
+
+ while (shiftVideoCodecs.Contains(videoCodecs[0], StringComparer.OrdinalIgnoreCase))
+ {
+ var removed = shiftVideoCodecs[0];
+ videoCodecs.RemoveAt(0);
+ videoCodecs.Add(removed);
+ }
+ }
+
private void NormalizeSubtitleEmbed(EncodingJobInfo state)
{
if (state.SubtitleStream == null || state.SubtitleDeliveryMethod != SubtitleDeliveryMethod.Embed)
@@ -2752,7 +3032,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var videoType = state.MediaSource.VideoType ?? VideoType.VideoFile;
// Only use alternative encoders for video files.
// When using concat with folder rips, if the mfx session fails to initialize, ffmpeg will be stuck retrying and will not exit gracefully
- // Since transcoding of folder rips is expiremental anyway, it's not worth adding additional variables such as this.
+ // Since transcoding of folder rips is experimental anyway, it's not worth adding additional variables such as this.
if (videoType != VideoType.VideoFile)
{
return null;
@@ -3352,7 +3632,7 @@ namespace MediaBrowser.Controller.MediaEncoding
args += " -ar " + state.OutputAudioSampleRate.Value.ToString(_usCulture);
}
- args += " " + GetAudioFilterParam(state, encodingOptions, false);
+ args += GetAudioFilterParam(state, encodingOptions, false);
return args;
}
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index db72fa56c..dacd6dea6 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -33,10 +33,6 @@ namespace MediaBrowser.Controller.MediaEncoding
public bool IsInputVideo { get; set; }
- public IIsoMount IsoMount { get; set; }
-
- public string[] PlayableStreamFileNames { get; set; }
-
public string OutputAudioCodec { get; set; }
public int? OutputVideoBitrate { get; set; }
@@ -313,7 +309,6 @@ namespace MediaBrowser.Controller.MediaEncoding
{
TranscodingType = jobType;
RemoteHttpHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
- PlayableStreamFileNames = Array.Empty<string>();
SupportedAudioCodecs = Array.Empty<string>();
SupportedVideoCodecs = Array.Empty<string>();
SupportedSubtitleCodecs = Array.Empty<string>();
@@ -593,6 +588,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{
get
{
+ if (VideoStream == null)
+ {
+ return null;
+ }
+
if (EncodingHelper.IsCopyCodec(OutputVideoCodec))
{
return VideoStream?.Codec;
@@ -606,6 +606,11 @@ namespace MediaBrowser.Controller.MediaEncoding
{
get
{
+ if (AudioStream == null)
+ {
+ return null;
+ }
+
if (EncodingHelper.IsCopyCodec(OutputAudioCodec))
{
return AudioStream?.Codec;
diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs
index f6bc1f4de..34fe895cc 100644
--- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs
+++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
@@ -61,18 +62,18 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <summary>
/// Extracts the video image.
/// </summary>
- Task<string> ExtractVideoImage(string[] inputFiles, string container, MediaProtocol protocol, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken);
+ Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken);
- Task<string> ExtractVideoImage(string[] inputFiles, string container, MediaProtocol protocol, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken);
+ Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken);
/// <summary>
/// Extracts the video images on interval.
/// </summary>
Task ExtractVideoImagesOnInterval(
- string[] inputFiles,
+ string inputFile,
string container,
MediaStream videoStream,
- MediaProtocol protocol,
+ MediaSourceInfo mediaSource,
Video3DFormat? threedFormat,
TimeSpan interval,
string targetDirectory,
@@ -91,10 +92,10 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <summary>
/// Gets the input argument.
/// </summary>
- /// <param name="inputFiles">The input files.</param>
- /// <param name="protocol">The protocol.</param>
+ /// <param name="inputFile">The input file.</param>
+ /// <param name="mediaSource">The mediaSource.</param>
/// <returns>System.String.</returns>
- string GetInputArgument(IReadOnlyList<string> inputFiles, MediaProtocol protocol);
+ string GetInputArgument(string inputFile, MediaSourceInfo mediaSource);
/// <summary>
/// Gets the time parameter.
@@ -116,6 +117,6 @@ namespace MediaBrowser.Controller.MediaEncoding
void UpdateEncoderPath(string path, string pathType);
- IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, IIsoMount isoMount, uint? titleNumber);
+ IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber);
}
}
diff --git a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs b/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs
index ce53c23ad..281d50372 100644
--- a/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs
+++ b/MediaBrowser.Controller/MediaEncoding/MediaEncoderHelpers.cs
@@ -13,38 +13,5 @@ namespace MediaBrowser.Controller.MediaEncoding
/// </summary>
public static class MediaEncoderHelpers
{
- /// <summary>
- /// Gets the input argument.
- /// </summary>
- /// <param name="fileSystem">The file system.</param>
- /// <param name="videoPath">The video path.</param>
- /// <param name="isoMount">The iso mount.</param>
- /// <param name="playableStreamFileNames">The playable stream file names.</param>
- /// <returns>string[].</returns>
- public static string[] GetInputArgument(IFileSystem fileSystem, string videoPath, IIsoMount isoMount, IReadOnlyCollection<string> playableStreamFileNames)
- {
- if (playableStreamFileNames.Count > 0)
- {
- if (isoMount == null)
- {
- return GetPlayableStreamFiles(fileSystem, videoPath, playableStreamFileNames);
- }
-
- return GetPlayableStreamFiles(fileSystem, isoMount.MountedPath, playableStreamFileNames);
- }
-
- return new[] { videoPath };
- }
-
- private static string[] GetPlayableStreamFiles(IFileSystem fileSystem, string rootPath, IEnumerable<string> filenames)
- {
- var allFiles = fileSystem
- .GetFilePaths(rootPath, true)
- .ToArray();
-
- return filenames.Select(name => allFiles.FirstOrDefault(f => string.Equals(Path.GetFileName(f), name, StringComparison.OrdinalIgnoreCase)))
- .Where(f => !string.IsNullOrEmpty(f))
- .ToArray();
- }
}
}
diff --git a/MediaBrowser.Controller/MediaEncoding/MediaInfoRequest.cs b/MediaBrowser.Controller/MediaEncoding/MediaInfoRequest.cs
index 59729de49..2cb04bdc4 100644
--- a/MediaBrowser.Controller/MediaEncoding/MediaInfoRequest.cs
+++ b/MediaBrowser.Controller/MediaEncoding/MediaInfoRequest.cs
@@ -1,9 +1,7 @@
#pragma warning disable CS1591
-using System;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
-using MediaBrowser.Model.IO;
namespace MediaBrowser.Controller.MediaEncoding
{
@@ -14,14 +12,5 @@ namespace MediaBrowser.Controller.MediaEncoding
public bool ExtractChapters { get; set; }
public DlnaProfileType MediaType { get; set; }
-
- public IIsoMount MountedIso { get; set; }
-
- public string[] PlayableStreamFileNames { get; set; }
-
- public MediaInfoRequest()
- {
- PlayableStreamFileNames = Array.Empty<string>();
- }
}
}
diff --git a/MediaBrowser.Controller/Net/AuthorizationInfo.cs b/MediaBrowser.Controller/Net/AuthorizationInfo.cs
index 0194c596f..93573e08e 100644
--- a/MediaBrowser.Controller/Net/AuthorizationInfo.cs
+++ b/MediaBrowser.Controller/Net/AuthorizationInfo.cs
@@ -58,5 +58,10 @@ namespace MediaBrowser.Controller.Net
/// Gets or sets a value indicating whether the token is authenticated.
/// </summary>
public bool IsAuthenticated { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the request has a token.
+ /// </summary>
+ public bool HasToken { get; set; }
}
}
diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
index 28227603b..163a9c8f8 100644
--- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
+++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
@@ -92,6 +92,9 @@ namespace MediaBrowser.Controller.Net
return Task.CompletedTask;
}
+ /// <inheritdoc />
+ public Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) => Task.CompletedTask;
+
/// <summary>
/// Starts sending messages over a web socket.
/// </summary>
diff --git a/MediaBrowser.Controller/Net/IWebSocketListener.cs b/MediaBrowser.Controller/Net/IWebSocketListener.cs
index 7250a57b0..f1a75d518 100644
--- a/MediaBrowser.Controller/Net/IWebSocketListener.cs
+++ b/MediaBrowser.Controller/Net/IWebSocketListener.cs
@@ -3,7 +3,7 @@ using System.Threading.Tasks;
namespace MediaBrowser.Controller.Net
{
/// <summary>
- ///This is an interface for listening to messages coming through a web socket connection.
+ /// Interface for listening to messages coming through a web socket connection.
/// </summary>
public interface IWebSocketListener
{
@@ -13,5 +13,12 @@ namespace MediaBrowser.Controller.Net
/// <param name="message">The message.</param>
/// <returns>Task.</returns>
Task ProcessMessageAsync(WebSocketMessageInfo message);
+
+ /// <summary>
+ /// Processes a new web socket connection.
+ /// </summary>
+ /// <param name="connection">An instance of the <see cref="IWebSocketConnection"/> interface.</param>
+ /// <returns>Task.</returns>
+ Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection);
}
}
diff --git a/MediaBrowser.Controller/Net/IWebSocketManager.cs b/MediaBrowser.Controller/Net/IWebSocketManager.cs
index ce74173e7..bb0ae83be 100644
--- a/MediaBrowser.Controller/Net/IWebSocketManager.cs
+++ b/MediaBrowser.Controller/Net/IWebSocketManager.cs
@@ -1,7 +1,4 @@
-using System;
-using System.Collections.Generic;
using System.Threading.Tasks;
-using Jellyfin.Data.Events;
using Microsoft.AspNetCore.Http;
namespace MediaBrowser.Controller.Net
@@ -12,11 +9,6 @@ namespace MediaBrowser.Controller.Net
public interface IWebSocketManager
{
/// <summary>
- /// Occurs when [web socket connected].
- /// </summary>
- event EventHandler<GenericEventArgs<IWebSocketConnection>> WebSocketConnected;
-
- /// <summary>
/// The HTTP request handler.
/// </summary>
/// <param name="context">The current HTTP context.</param>
diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs
index fbf2c5213..f6c592070 100644
--- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs
+++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs
@@ -31,7 +31,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, ICollection<Guid> itemIds, Guid userId);
+ Task AddToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId);
/// <summary>
/// Removes from playlist.
diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs
index 996ec27c0..0a4967223 100644
--- a/MediaBrowser.Controller/Providers/IProviderManager.cs
+++ b/MediaBrowser.Controller/Providers/IProviderManager.cs
@@ -46,6 +46,14 @@ namespace MediaBrowser.Controller.Providers
Task<ItemUpdateType> RefreshSingleItem(BaseItem item, MetadataRefreshOptions options, CancellationToken cancellationToken);
/// <summary>
+ /// Runs multiple metadata refreshes concurrently.
+ /// </summary>
+ /// <param name="action">The action to run.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
+ Task RunMetadataRefresh(Func<Task> action, CancellationToken cancellationToken);
+
+ /// <summary>
/// Saves the image.
/// </summary>
/// <param name="item">The item.</param>
diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs
index 04c3004ee..9ad8557ce 100644
--- a/MediaBrowser.Controller/Session/ISessionManager.cs
+++ b/MediaBrowser.Controller/Session/ISessionManager.cs
@@ -143,22 +143,22 @@ namespace MediaBrowser.Controller.Session
Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken);
/// <summary>
- /// Sends the SyncPlayCommand.
+ /// Sends a SyncPlayCommand to a session.
/// </summary>
- /// <param name="sessionId">The session id.</param>
+ /// <param name="session">The session.</param>
/// <param name="command">The command.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken);
+ Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken);
/// <summary>
- /// Sends the SyncPlayGroupUpdate.
+ /// Sends a SyncPlayGroupUpdate to a session.
/// </summary>
- /// <param name="sessionId">The session id.</param>
+ /// <param name="session">The session.</param>
/// <param name="command">The group update.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken);
+ Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken);
/// <summary>
/// Sends the browse command.
diff --git a/MediaBrowser.Controller/Session/SessionInfo.cs b/MediaBrowser.Controller/Session/SessionInfo.cs
index ce58a60b9..d09852870 100644
--- a/MediaBrowser.Controller/Session/SessionInfo.cs
+++ b/MediaBrowser.Controller/Session/SessionInfo.cs
@@ -1,6 +1,7 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
@@ -54,7 +55,7 @@ namespace MediaBrowser.Controller.Session
/// Gets or sets the playable media types.
/// </summary>
/// <value>The playable media types.</value>
- public string[] PlayableMediaTypes
+ public IReadOnlyList<string> PlayableMediaTypes
{
get
{
@@ -230,7 +231,7 @@ namespace MediaBrowser.Controller.Session
/// Gets or sets the supported commands.
/// </summary>
/// <value>The supported commands.</value>
- public GeneralCommandType[] SupportedCommands
+ public IReadOnlyList<GeneralCommandType> SupportedCommands
=> Capabilities == null ? Array.Empty<GeneralCommandType>() : Capabilities.SupportedCommands;
public Tuple<ISessionController, bool> EnsureController<T>(Func<SessionInfo, ISessionController> factory)
diff --git a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs b/MediaBrowser.Controller/SyncPlay/GroupInfo.cs
deleted file mode 100644
index a1cada25c..000000000
--- a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs
+++ /dev/null
@@ -1,160 +0,0 @@
-using System;
-using System.Collections.Generic;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Session;
-
-namespace MediaBrowser.Controller.SyncPlay
-{
- /// <summary>
- /// Class GroupInfo.
- /// </summary>
- /// <remarks>
- /// Class is not thread-safe, external locking is required when accessing methods.
- /// </remarks>
- public class GroupInfo
- {
- /// <summary>
- /// The default ping value used for sessions.
- /// </summary>
- public const long DefaultPing = 500;
-
- /// <summary>
- /// Gets the group identifier.
- /// </summary>
- /// <value>The group identifier.</value>
- public Guid GroupId { get; } = Guid.NewGuid();
-
- /// <summary>
- /// Gets or sets the playing item.
- /// </summary>
- /// <value>The playing item.</value>
- public BaseItem PlayingItem { get; set; }
-
- /// <summary>
- /// Gets or sets a value indicating whether playback is paused.
- /// </summary>
- /// <value>Playback is paused.</value>
- public bool IsPaused { get; set; }
-
- /// <summary>
- /// Gets or sets a value indicating whether there are position ticks.
- /// </summary>
- /// <value>The position ticks.</value>
- public long PositionTicks { get; set; }
-
- /// <summary>
- /// Gets or sets the last activity.
- /// </summary>
- /// <value>The last activity.</value>
- public DateTime LastActivity { get; set; }
-
- /// <summary>
- /// Gets the participants.
- /// </summary>
- /// <value>The participants, or members of the group.</value>
- public Dictionary<string, GroupMember> Participants { get; } =
- new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
-
- /// <summary>
- /// Checks if a session is in this group.
- /// </summary>
- /// <param name="sessionId">The session id to check.</param>
- /// <returns><c>true</c> if the session is in this group; <c>false</c> otherwise.</returns>
- public bool ContainsSession(string sessionId)
- {
- return Participants.ContainsKey(sessionId);
- }
-
- /// <summary>
- /// Adds the session to the group.
- /// </summary>
- /// <param name="session">The session.</param>
- public void AddSession(SessionInfo session)
- {
- Participants.TryAdd(
- session.Id,
- new GroupMember
- {
- Session = session,
- Ping = DefaultPing,
- IsBuffering = false
- });
- }
-
- /// <summary>
- /// Removes the session from the group.
- /// </summary>
- /// <param name="session">The session.</param>
- public void RemoveSession(SessionInfo session)
- {
- Participants.Remove(session.Id);
- }
-
- /// <summary>
- /// Updates the ping of a session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="ping">The ping.</param>
- public void UpdatePing(SessionInfo session, long ping)
- {
- if (Participants.TryGetValue(session.Id, out GroupMember value))
- {
- value.Ping = ping;
- }
- }
-
- /// <summary>
- /// Gets the highest ping in the group.
- /// </summary>
- /// <returns>The highest ping in the group.</returns>
- public long GetHighestPing()
- {
- long max = long.MinValue;
- foreach (var session in Participants.Values)
- {
- max = Math.Max(max, session.Ping);
- }
-
- return max;
- }
-
- /// <summary>
- /// Sets the session's buffering state.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="isBuffering">The state.</param>
- public void SetBuffering(SessionInfo session, bool isBuffering)
- {
- if (Participants.TryGetValue(session.Id, out GroupMember value))
- {
- value.IsBuffering = isBuffering;
- }
- }
-
- /// <summary>
- /// Gets the group buffering state.
- /// </summary>
- /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns>
- public bool IsBuffering()
- {
- foreach (var session in Participants.Values)
- {
- if (session.IsBuffering)
- {
- return true;
- }
- }
-
- return false;
- }
-
- /// <summary>
- /// Checks if the group is empty.
- /// </summary>
- /// <returns><c>true</c> if the group is empty; <c>false</c> otherwise.</returns>
- public bool IsEmpty()
- {
- return Participants.Count == 0;
- }
- }
-}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupMember.cs b/MediaBrowser.Controller/SyncPlay/GroupMember.cs
index cde6f8e8c..5fb982e85 100644
--- a/MediaBrowser.Controller/SyncPlay/GroupMember.cs
+++ b/MediaBrowser.Controller/SyncPlay/GroupMember.cs
@@ -8,21 +8,36 @@ namespace MediaBrowser.Controller.SyncPlay
public class GroupMember
{
/// <summary>
- /// Gets or sets a value indicating whether this member is buffering.
+ /// Initializes a new instance of the <see cref="GroupMember"/> class.
/// </summary>
- /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value>
- public bool IsBuffering { get; set; }
+ /// <param name="session">The session.</param>
+ public GroupMember(SessionInfo session)
+ {
+ Session = session;
+ }
/// <summary>
- /// Gets or sets the session.
+ /// Gets the session.
/// </summary>
/// <value>The session.</value>
- public SessionInfo Session { get; set; }
+ public SessionInfo Session { get; }
/// <summary>
- /// Gets or sets the ping.
+ /// Gets or sets the ping, in milliseconds.
/// </summary>
/// <value>The ping.</value>
public long Ping { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this member is buffering.
+ /// </summary>
+ /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value>
+ public bool IsBuffering { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether this member is following group playback.
+ /// </summary>
+ /// <value><c>true</c> to ignore member on group wait; <c>false</c> if they're following group playback.</value>
+ public bool IgnoreGroupWait { get; set; }
}
}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs
new file mode 100644
index 000000000..e3de22db3
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs
@@ -0,0 +1,222 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class AbstractGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public abstract class AbstractGroupState : IGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<AbstractGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AbstractGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ protected AbstractGroupState(ILoggerFactory loggerFactory)
+ {
+ LoggerFactory = loggerFactory;
+ _logger = loggerFactory.CreateLogger<AbstractGroupState>();
+ }
+
+ /// <inheritdoc />
+ public abstract GroupStateType Type { get; }
+
+ /// <summary>
+ /// Gets the logger factory.
+ /// </summary>
+ protected ILoggerFactory LoggerFactory { get; }
+
+ /// <inheritdoc />
+ public abstract void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <inheritdoc />
+ public abstract void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var playingItemRemoved = context.RemoveFromPlayQueue(request.PlaylistItemIds);
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RemoveItems);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ if (playingItemRemoved && !context.PlayQueue.IsItemPlaying())
+ {
+ _logger.LogDebug("Play queue in group {GroupId} is now empty.", context.GroupId.ToString());
+
+ IGroupState idleState = new IdleGroupState(LoggerFactory);
+ context.SetState(idleState);
+ var stopRequest = new StopGroupRequest();
+ idleState.HandleRequest(stopRequest, context, Type, session, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var result = context.MoveItemInPlayQueue(request.PlaylistItemId, request.NewIndex);
+
+ if (!result)
+ {
+ _logger.LogError("Unable to move item in group {GroupId}.", context.GroupId.ToString());
+ return;
+ }
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.MoveItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var result = context.AddToPlayQueue(request.ItemIds, request.Mode);
+
+ if (!result)
+ {
+ _logger.LogError("Unable to add items to play queue in group {GroupId}.", context.GroupId.ToString());
+ return;
+ }
+
+ var reason = request.Mode switch
+ {
+ GroupQueueMode.QueueNext => PlayQueueUpdateReason.QueueNext,
+ _ => PlayQueueUpdateReason.Queue
+ };
+ var playQueueUpdate = context.GetPlayQueueUpdate(reason);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ UnhandledRequest(request);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ context.SetRepeatMode(request.Mode);
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RepeatMode);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ context.SetShuffleMode(request.Mode);
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.ShuffleMode);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Collected pings are used to account for network latency when unpausing playback.
+ context.UpdatePing(session, request.Ping);
+ }
+
+ /// <inheritdoc />
+ public virtual void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ context.SetIgnoreGroupWait(session, request.IgnoreWait);
+ }
+
+ /// <summary>
+ /// Sends a group state update to all group.
+ /// </summary>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="reason">The reason of the state change.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ protected void SendGroupStateUpdate(IGroupStateContext context, IGroupPlaybackRequest reason, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Notify relevant state change event.
+ var stateUpdate = new GroupStateUpdate(Type, reason.Action);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.StateUpdate, stateUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+ }
+
+ private void UnhandledRequest(IGroupPlaybackRequest request)
+ {
+ _logger.LogWarning("Unhandled request of type {RequestType} in {StateType} state.", request.Action, Type);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs
new file mode 100644
index 000000000..12ce6c8f8
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs
@@ -0,0 +1,126 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class IdleGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class IdleGroupState : AbstractGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<IdleGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IdleGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public IdleGroupState(ILoggerFactory loggerFactory)
+ : base(loggerFactory)
+ {
+ _logger = LoggerFactory.CreateLogger<IdleGroupState>();
+ }
+
+ /// <inheritdoc />
+ public override GroupStateType Type { get; } = GroupStateType.Idle;
+
+ /// <inheritdoc />
+ public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Do nothing.
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ SendStopCommand(context, prevState, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ private void SendStopCommand(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ var command = context.NewSyncPlayCommand(SendCommandType.Stop);
+ if (!prevState.Equals(Type))
+ {
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+ }
+ else
+ {
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs
new file mode 100644
index 000000000..fba8ba9e2
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class PausedGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class PausedGroupState : AbstractGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<PausedGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PausedGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public PausedGroupState(ILoggerFactory loggerFactory)
+ : base(loggerFactory)
+ {
+ _logger = LoggerFactory.CreateLogger<PausedGroupState>();
+ }
+
+ /// <inheritdoc />
+ public override GroupStateType Type { get; } = GroupStateType.Paused;
+
+ /// <inheritdoc />
+ public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Wait for session to be ready.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.SessionJoined(context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Do nothing.
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var playingState = new PlayingGroupState(LoggerFactory);
+ context.SetState(playingState);
+ playingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (!prevState.Equals(Type))
+ {
+ // Pause group and compute the media playback position.
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime - context.LastActivity;
+ context.LastActivity = currentTime;
+ // Elapsed time is negative if event happens
+ // during the delay added to account for latency.
+ // In this phase clients haven't started the playback yet.
+ // In other words, LastActivity is in the future,
+ // when playback unpause is supposed to happen.
+ // Seek only if playback actually started.
+ context.PositionTicks += Math.Max(elapsedTime.Ticks, 0);
+
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ else
+ {
+ // Client got lost, sending current state.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var idleState = new IdleGroupState(LoggerFactory);
+ context.SetState(idleState);
+ idleState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (prevState.Equals(Type))
+ {
+ // Client got lost, sending current state.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ else if (prevState.Equals(GroupStateType.Waiting))
+ {
+ // Sending current state to all clients.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs
new file mode 100644
index 000000000..9797b247c
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class PlayingGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class PlayingGroupState : AbstractGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<PlayingGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayingGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public PlayingGroupState(ILoggerFactory loggerFactory)
+ : base(loggerFactory)
+ {
+ _logger = LoggerFactory.CreateLogger<PlayingGroupState>();
+ }
+
+ /// <inheritdoc />
+ public override GroupStateType Type { get; } = GroupStateType.Playing;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether requests for buffering should be ignored.
+ /// </summary>
+ public bool IgnoreBuffering { get; set; }
+
+ /// <inheritdoc />
+ public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Wait for session to be ready.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.SessionJoined(context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Do nothing.
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (!prevState.Equals(Type))
+ {
+ // Pick a suitable time that accounts for latency.
+ var delayMillis = Math.Max(context.GetHighestPing() * 2, context.DefaultPing);
+
+ // Unpause group and set starting point in future.
+ // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position).
+ // The added delay does not guarantee, of course, that the command will be received in time.
+ // Playback synchronization will mainly happen client side.
+ context.LastActivity = DateTime.UtcNow.AddMilliseconds(delayMillis);
+
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ else
+ {
+ // Client got lost, sending current state.
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var pausedState = new PausedGroupState(LoggerFactory);
+ context.SetState(pausedState);
+ pausedState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var idleState = new IdleGroupState(LoggerFactory);
+ context.SetState(idleState);
+ idleState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (IgnoreBuffering)
+ {
+ return;
+ }
+
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ if (prevState.Equals(Type))
+ {
+ // Group was not waiting, make sure client has latest state.
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ else if (prevState.Equals(GroupStateType.Waiting))
+ {
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Change state.
+ var waitingState = new WaitingGroupState(LoggerFactory);
+ context.SetState(waitingState);
+ waitingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
new file mode 100644
index 000000000..507573653
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs
@@ -0,0 +1,680 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Controller.SyncPlay.GroupStates
+{
+ /// <summary>
+ /// Class WaitingGroupState.
+ /// </summary>
+ /// <remarks>
+ /// Class is not thread-safe, external locking is required when accessing methods.
+ /// </remarks>
+ public class WaitingGroupState : AbstractGroupState
+ {
+ /// <summary>
+ /// The logger.
+ /// </summary>
+ private readonly ILogger<WaitingGroupState> _logger;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="WaitingGroupState"/> class.
+ /// </summary>
+ /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param>
+ public WaitingGroupState(ILoggerFactory loggerFactory)
+ : base(loggerFactory)
+ {
+ _logger = LoggerFactory.CreateLogger<WaitingGroupState>();
+ }
+
+ /// <inheritdoc />
+ public override GroupStateType Type { get; } = GroupStateType.Waiting;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether playback should resume when group is ready.
+ /// </summary>
+ public bool ResumePlaying { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the initial state has been set.
+ /// </summary>
+ private bool InitialStateSet { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets the group state before the first ever event.
+ /// </summary>
+ private GroupStateType InitialState { get; set; }
+
+ /// <inheritdoc />
+ public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ if (prevState.Equals(GroupStateType.Playing))
+ {
+ ResumePlaying = true;
+ // Pause group and compute the media playback position.
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime - context.LastActivity;
+ context.LastActivity = currentTime;
+ // Elapsed time is negative if event happens
+ // during the delay added to account for latency.
+ // In this phase clients haven't started the playback yet.
+ // In other words, LastActivity is in the future,
+ // when playback unpause is supposed to happen.
+ // Seek only if playback actually started.
+ context.PositionTicks += Math.Max(elapsedTime.Ticks, 0);
+ }
+
+ // Prepare new session.
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken);
+
+ context.SetBuffering(session, true);
+
+ // Send pause command to all non-buffering sessions.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ context.SetBuffering(session, false);
+
+ if (!context.IsBuffering())
+ {
+ if (ResumePlaying)
+ {
+ _logger.LogDebug("Session {SessionId} left group {GroupId}, notifying others to resume.", session.Id, context.GroupId.ToString());
+
+ // Client, that was buffering, left the group.
+ var playingState = new PlayingGroupState(LoggerFactory);
+ context.SetState(playingState);
+ var unpauseRequest = new UnpauseGroupRequest();
+ playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken);
+ }
+ else
+ {
+ _logger.LogDebug("Session {SessionId} left group {GroupId}, returning to previous state.", session.Id, context.GroupId.ToString());
+
+ // Group is ready, returning to previous state.
+ var pausedState = new PausedGroupState(LoggerFactory);
+ context.SetState(pausedState);
+ }
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ ResumePlaying = true;
+
+ var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks);
+ if (!setQueueStatus)
+ {
+ _logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString());
+
+ // Ignore request and return to previous state.
+ IGroupState newState = prevState switch {
+ GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+ GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+ _ => new IdleGroupState(LoggerFactory)
+ };
+
+ context.SetState(newState);
+ return;
+ }
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+
+ _logger.LogDebug("Session {SessionId} set a new play queue in group {GroupId}.", session.Id, context.GroupId.ToString());
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ ResumePlaying = true;
+
+ var result = context.SetPlayingItem(request.PlaylistItemId);
+ if (result)
+ {
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+ }
+ else
+ {
+ // Return to old state.
+ IGroupState newState = prevState switch
+ {
+ GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+ GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+ _ => new IdleGroupState(LoggerFactory)
+ };
+
+ context.SetState(newState);
+
+ _logger.LogDebug("Unable to change current playing item in group {GroupId}.", context.GroupId.ToString());
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ if (prevState.Equals(GroupStateType.Idle))
+ {
+ ResumePlaying = true;
+ context.RestartCurrentItem();
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+
+ _logger.LogDebug("Group {GroupId} is waiting for all ready events.", context.GroupId.ToString());
+ }
+ else
+ {
+ if (ResumePlaying)
+ {
+ _logger.LogDebug("Forcing the playback to start in group {GroupId}. Group-wait is disabled until next state change.", context.GroupId.ToString());
+
+ // An Unpause request is forcing the playback to start, ignoring sessions that are not ready.
+ context.SetAllBuffering(false);
+
+ // Change state.
+ var playingState = new PlayingGroupState(LoggerFactory)
+ {
+ IgnoreBuffering = true
+ };
+ context.SetState(playingState);
+ playingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ else
+ {
+ // Group would have gone to paused state, now will go to playing state when ready.
+ ResumePlaying = true;
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ // Wait for sessions to be ready, then switch to paused state.
+ ResumePlaying = false;
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ // Change state.
+ var idleState = new IdleGroupState(LoggerFactory);
+ context.SetState(idleState);
+ idleState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ if (prevState.Equals(GroupStateType.Playing))
+ {
+ ResumePlaying = true;
+ }
+ else if (prevState.Equals(GroupStateType.Paused))
+ {
+ ResumePlaying = false;
+ }
+
+ // Sanitize PositionTicks.
+ var ticks = context.SanitizePositionTicks(request.PositionTicks);
+
+ // Seek.
+ context.PositionTicks = ticks;
+ context.LastActivity = DateTime.UtcNow;
+
+ var command = context.NewSyncPlayCommand(SendCommandType.Seek);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ // Make sure the client is playing the correct item.
+ if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId()))
+ {
+ _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString());
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem);
+ var updateSession = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken);
+ context.SetBuffering(session, true);
+
+ return;
+ }
+
+ if (prevState.Equals(GroupStateType.Playing))
+ {
+ // Resume playback when all ready.
+ ResumePlaying = true;
+
+ context.SetBuffering(session, true);
+
+ // Pause group and compute the media playback position.
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime - context.LastActivity;
+ context.LastActivity = currentTime;
+ // Elapsed time is negative if event happens
+ // during the delay added to account for latency.
+ // In this phase clients haven't started the playback yet.
+ // In other words, LastActivity is in the future,
+ // when playback unpause is supposed to happen.
+ // Seek only if playback actually started.
+ context.PositionTicks += Math.Max(elapsedTime.Ticks, 0);
+
+ // Send pause command to all non-buffering sessions.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken);
+ }
+ else if (prevState.Equals(GroupStateType.Paused))
+ {
+ // Don't resume playback when all ready.
+ ResumePlaying = false;
+
+ context.SetBuffering(session, true);
+
+ // Send pause command to buffering session.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ else if (prevState.Equals(GroupStateType.Waiting))
+ {
+ // Another session is now buffering.
+ context.SetBuffering(session, true);
+
+ if (!ResumePlaying)
+ {
+ // Force update for this session that should be paused.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+ }
+ }
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ // Make sure the client is playing the correct item.
+ if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId()))
+ {
+ _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString());
+
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken);
+ context.SetBuffering(session, true);
+
+ return;
+ }
+
+ // Compute elapsed time between the client reported time and now.
+ // Elapsed time is used to estimate the client position when playback is unpaused.
+ // Ideally, the request is received and handled without major delays.
+ // However, to avoid waiting indefinitely when a client is not reporting a correct time,
+ // the elapsed time is ignored after a certain threshold.
+ var currentTime = DateTime.UtcNow;
+ var elapsedTime = currentTime.Subtract(request.When);
+ var timeSyncThresholdTicks = TimeSpan.FromMilliseconds(context.TimeSyncOffset).Ticks;
+ if (Math.Abs(elapsedTime.Ticks) > timeSyncThresholdTicks)
+ {
+ _logger.LogWarning("Session {SessionId} is not time syncing properly. Ignoring elapsed time.", session.Id);
+
+ elapsedTime = TimeSpan.Zero;
+ }
+
+ // Ignore elapsed time if client is paused.
+ if (!request.IsPlaying)
+ {
+ elapsedTime = TimeSpan.Zero;
+ }
+
+ var requestTicks = context.SanitizePositionTicks(request.PositionTicks);
+ var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime;
+ var delayTicks = context.PositionTicks - clientPosition.Ticks;
+ var maxPlaybackOffsetTicks = TimeSpan.FromMilliseconds(context.MaxPlaybackOffset).Ticks;
+
+ _logger.LogDebug("Session {SessionId} is at {PositionTicks} (delay of {Delay} seconds) in group {GroupId}.", session.Id, clientPosition, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString());
+
+ if (ResumePlaying)
+ {
+ // Handle case where session reported as ready but in reality
+ // it has no clue of the real position nor the playback state.
+ if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks)
+ {
+ // Session not ready at all.
+ context.SetBuffering(session, true);
+
+ // Correcting session's position.
+ var command = context.NewSyncPlayCommand(SendCommandType.Seek);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+
+ _logger.LogWarning("Session {SessionId} got lost in time, correcting.", session.Id);
+ return;
+ }
+
+ // Session is ready.
+ context.SetBuffering(session, false);
+
+ if (context.IsBuffering())
+ {
+ // Others are still buffering, tell this client to pause when ready.
+ var command = context.NewSyncPlayCommand(SendCommandType.Pause);
+ command.When = currentTime.AddTicks(delayTicks);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} will pause when ready in {Delay} seconds. Group {GroupId} is waiting for all ready events.", session.Id, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString());
+ }
+ else
+ {
+ // If all ready, then start playback.
+ // Let other clients resume as soon as the buffering client catches up.
+ if (delayTicks > context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond)
+ {
+ // Client that was buffering is recovering, notifying others to resume.
+ context.LastActivity = currentTime.AddTicks(delayTicks);
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ var filter = SyncPlayBroadcastType.AllExceptCurrentSession;
+ if (!request.IsPlaying)
+ {
+ filter = SyncPlayBroadcastType.AllGroup;
+ }
+
+ context.SendCommand(session, filter, command, cancellationToken);
+
+ _logger.LogInformation("Session {SessionId} is recovering, group {GroupId} will resume in {Delay} seconds.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds);
+ }
+ else
+ {
+ // Client, that was buffering, resumed playback but did not update others in time.
+ delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond;
+ delayTicks = Math.Max(delayTicks, context.DefaultPing);
+
+ context.LastActivity = currentTime.AddTicks(delayTicks);
+
+ var command = context.NewSyncPlayCommand(SendCommandType.Unpause);
+ context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken);
+
+ _logger.LogWarning("Session {SessionId} resumed playback, group {GroupId} has {Delay} seconds to recover.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds);
+ }
+
+ // Change state.
+ var playingState = new PlayingGroupState(LoggerFactory);
+ context.SetState(playingState);
+ playingState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ }
+ else
+ {
+ // Check that session is really ready, tolerate player imperfections under a certain threshold.
+ if (Math.Abs(context.PositionTicks - requestTicks) > maxPlaybackOffsetTicks)
+ {
+ // Session still not ready.
+ context.SetBuffering(session, true);
+ // Session is seeking to wrong position, correcting.
+ var command = context.NewSyncPlayCommand(SendCommandType.Seek);
+ context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken);
+
+ // Notify relevant state change event.
+ SendGroupStateUpdate(context, request, session, cancellationToken);
+
+ _logger.LogWarning("Session {SessionId} is seeking to wrong position, correcting.", session.Id);
+ return;
+ }
+ else
+ {
+ // Session is ready.
+ context.SetBuffering(session, false);
+ }
+
+ if (!context.IsBuffering())
+ {
+ _logger.LogDebug("Session {SessionId} is ready, group {GroupId} is ready.", session.Id, context.GroupId.ToString());
+
+ // Group is ready, returning to previous state.
+ var pausedState = new PausedGroupState(LoggerFactory);
+ context.SetState(pausedState);
+
+ if (InitialState.Equals(GroupStateType.Playing))
+ {
+ // Group went from playing to waiting state and a pause request occured while waiting.
+ var pauseRequest = new PauseGroupRequest();
+ pausedState.HandleRequest(pauseRequest, context, Type, session, cancellationToken);
+ }
+ else if (InitialState.Equals(GroupStateType.Paused))
+ {
+ pausedState.HandleRequest(request, context, Type, session, cancellationToken);
+ }
+ }
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ ResumePlaying = true;
+
+ // Make sure the client knows the playing item, to avoid duplicate requests.
+ if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId()))
+ {
+ _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString());
+ return;
+ }
+
+ var newItem = context.NextItemInQueue();
+ if (newItem)
+ {
+ // Send playing-queue update.
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NextItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+ }
+ else
+ {
+ // Return to old state.
+ IGroupState newState = prevState switch
+ {
+ GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+ GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+ _ => new IdleGroupState(LoggerFactory)
+ };
+
+ context.SetState(newState);
+
+ _logger.LogDebug("No next item available in group {GroupId}.", context.GroupId.ToString());
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ // Save state if first event.
+ if (!InitialStateSet)
+ {
+ InitialState = prevState;
+ InitialStateSet = true;
+ }
+
+ ResumePlaying = true;
+
+ // Make sure the client knows the playing item, to avoid duplicate requests.
+ if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId()))
+ {
+ _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString());
+ return;
+ }
+
+ var newItem = context.PreviousItemInQueue();
+ if (newItem)
+ {
+ // Send playing-queue update.
+ var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.PreviousItem);
+ var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate);
+ context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken);
+
+ // Reset status of sessions and await for all Ready events.
+ context.SetAllBuffering(true);
+ }
+ else
+ {
+ // Return to old state.
+ IGroupState newState = prevState switch
+ {
+ GroupStateType.Playing => new PlayingGroupState(LoggerFactory),
+ GroupStateType.Paused => new PausedGroupState(LoggerFactory),
+ _ => new IdleGroupState(LoggerFactory)
+ };
+
+ context.SetState(newState);
+
+ _logger.LogDebug("No previous item available in group {GroupId}.", context.GroupId.ToString());
+ }
+ }
+
+ /// <inheritdoc />
+ public override void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken)
+ {
+ context.SetIgnoreGroupWait(session, request.IgnoreWait);
+
+ if (!context.IsBuffering())
+ {
+ _logger.LogDebug("Ignoring session {SessionId}, group {GroupId} is ready.", session.Id, context.GroupId.ToString());
+
+ if (ResumePlaying)
+ {
+ // Client, that was buffering, stopped following playback.
+ var playingState = new PlayingGroupState(LoggerFactory);
+ context.SetState(playingState);
+ var unpauseRequest = new UnpauseGroupRequest();
+ playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken);
+ }
+ else
+ {
+ // Group is ready, returning to previous state.
+ var pausedState = new PausedGroupState(LoggerFactory);
+ context.SetState(pausedState);
+ }
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs
new file mode 100644
index 000000000..201f29952
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs
@@ -0,0 +1,27 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+ /// <summary>
+ /// Interface IGroupPlaybackRequest.
+ /// </summary>
+ public interface IGroupPlaybackRequest : ISyncPlayRequest
+ {
+ /// <summary>
+ /// Gets the playback request type.
+ /// </summary>
+ /// <returns>The playback request type.</returns>
+ PlaybackRequestType Action { get; }
+
+ /// <summary>
+ /// Applies the request to a group.
+ /// </summary>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="state">The current state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken);
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/IGroupState.cs b/MediaBrowser.Controller/SyncPlay/IGroupState.cs
new file mode 100644
index 000000000..95ee09985
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/IGroupState.cs
@@ -0,0 +1,217 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+ /// <summary>
+ /// Interface IGroupState.
+ /// </summary>
+ public interface IGroupState
+ {
+ /// <summary>
+ /// Gets the group state type.
+ /// </summary>
+ /// <value>The group state type.</value>
+ GroupStateType Type { get; }
+
+ /// <summary>
+ /// Handles a session that joined the group.
+ /// </summary>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a session that is leaving the group.
+ /// </summary>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Generic handler. Context's state can change.
+ /// </summary>
+ /// <param name="request">The generic request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a play request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The play request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a set-playlist-item request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The set-playlist-item request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a remove-items request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The remove-items request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a move-playlist-item request from a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The move-playlist-item request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a queue request from a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The queue request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles an unpause request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The unpause request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a pause request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The pause request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a stop request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The stop request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a seek request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The seek request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a buffer request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The buffer request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a ready request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The ready request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a next-item request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The next-item request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a previous-item request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The previous-item request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a set-repeat-mode request from a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The repeat-mode request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a set-shuffle-mode request from a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The shuffle-mode request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Updates the ping of a session. Context's state should not change.
+ /// </summary>
+ /// <param name="request">The ping request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Handles a ignore-wait request from a session. Context's state can change.
+ /// </summary>
+ /// <param name="request">The ignore-wait request.</param>
+ /// <param name="context">The context of the state.</param>
+ /// <param name="prevState">The previous state.</param>
+ /// <param name="session">The session.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken);
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs
new file mode 100644
index 000000000..aa263638a
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.Queue;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+ /// <summary>
+ /// Interface IGroupStateContext.
+ /// </summary>
+ public interface IGroupStateContext
+ {
+ /// <summary>
+ /// Gets the default ping value used for sessions, in milliseconds.
+ /// </summary>
+ /// <value>The default ping value used for sessions, in milliseconds.</value>
+ long DefaultPing { get; }
+
+ /// <summary>
+ /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
+ /// </summary>
+ /// <value>The maximum offset error accepted, in milliseconds.</value>
+ long TimeSyncOffset { get; }
+
+ /// <summary>
+ /// Gets the maximum offset error accepted for position reported by clients, in milliseconds.
+ /// </summary>
+ /// <value>The maximum offset error accepted, in milliseconds.</value>
+ long MaxPlaybackOffset { get; }
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The group identifier.</value>
+ Guid GroupId { get; }
+
+ /// <summary>
+ /// Gets or sets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ long PositionTicks { get; set; }
+
+ /// <summary>
+ /// Gets or sets the last activity.
+ /// </summary>
+ /// <value>The last activity.</value>
+ DateTime LastActivity { get; set; }
+
+ /// <summary>
+ /// Gets the play queue.
+ /// </summary>
+ /// <value>The play queue.</value>
+ PlayQueueManager PlayQueue { get; }
+
+ /// <summary>
+ /// Sets a new state.
+ /// </summary>
+ /// <param name="state">The new state.</param>
+ void SetState(IGroupState state);
+
+ /// <summary>
+ /// Sends a GroupUpdate message to the interested sessions.
+ /// </summary>
+ /// <typeparam name="T">The type of the data of the message.</typeparam>
+ /// <param name="from">The current session.</param>
+ /// <param name="type">The filtering type.</param>
+ /// <param name="message">The message to send.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The task.</returns>
+ Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Sends a playback command to the interested sessions.
+ /// </summary>
+ /// <param name="from">The current session.</param>
+ /// <param name="type">The filtering type.</param>
+ /// <param name="message">The message to send.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>The task.</returns>
+ Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken);
+
+ /// <summary>
+ /// Builds a new playback command with some default values.
+ /// </summary>
+ /// <param name="type">The command type.</param>
+ /// <returns>The command.</returns>
+ SendCommand NewSyncPlayCommand(SendCommandType type);
+
+ /// <summary>
+ /// Builds a new group update message.
+ /// </summary>
+ /// <typeparam name="T">The type of the data of the message.</typeparam>
+ /// <param name="type">The update type.</param>
+ /// <param name="data">The data to send.</param>
+ /// <returns>The group update.</returns>
+ GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data);
+
+ /// <summary>
+ /// Sanitizes the PositionTicks, considers the current playing item when available.
+ /// </summary>
+ /// <param name="positionTicks">The PositionTicks.</param>
+ /// <returns>The sanitized position ticks.</returns>
+ long SanitizePositionTicks(long? positionTicks);
+
+ /// <summary>
+ /// Updates the ping of a session, in milliseconds.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="ping">The ping, in milliseconds.</param>
+ void UpdatePing(SessionInfo session, long ping);
+
+ /// <summary>
+ /// Gets the highest ping in the group, in milliseconds.
+ /// </summary>
+ /// <returns>The highest ping in the group.</returns>
+ long GetHighestPing();
+
+ /// <summary>
+ /// Sets the session's buffering state.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="isBuffering">The state.</param>
+ void SetBuffering(SessionInfo session, bool isBuffering);
+
+ /// <summary>
+ /// Sets the buffering state of all the sessions.
+ /// </summary>
+ /// <param name="isBuffering">The state.</param>
+ void SetAllBuffering(bool isBuffering);
+
+ /// <summary>
+ /// Gets the group buffering state.
+ /// </summary>
+ /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns>
+ bool IsBuffering();
+
+ /// <summary>
+ /// Sets the session's group wait state.
+ /// </summary>
+ /// <param name="session">The session.</param>
+ /// <param name="ignoreGroupWait">The state.</param>
+ void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait);
+
+ /// <summary>
+ /// Sets a new play queue.
+ /// </summary>
+ /// <param name="playQueue">The new play queue.</param>
+ /// <param name="playingItemPosition">The playing item position in the play queue.</param>
+ /// <param name="startPositionTicks">The start position ticks.</param>
+ /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns>
+ bool SetPlayQueue(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks);
+
+ /// <summary>
+ /// Sets the playing item.
+ /// </summary>
+ /// <param name="playlistItemId">The new playing item identifier.</param>
+ /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns>
+ bool SetPlayingItem(Guid playlistItemId);
+
+ /// <summary>
+ /// Removes items from the play queue.
+ /// </summary>
+ /// <param name="playlistItemIds">The items to remove.</param>
+ /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns>
+ bool RemoveFromPlayQueue(IReadOnlyList<Guid> playlistItemIds);
+
+ /// <summary>
+ /// Moves an item in the play queue.
+ /// </summary>
+ /// <param name="playlistItemId">The playlist identifier of the item to move.</param>
+ /// <param name="newIndex">The new position.</param>
+ /// <returns><c>true</c> if item has been moved; <c>false</c> if something went wrong.</returns>
+ bool MoveItemInPlayQueue(Guid playlistItemId, int newIndex);
+
+ /// <summary>
+ /// Updates the play queue.
+ /// </summary>
+ /// <param name="newItems">The new items to add to the play queue.</param>
+ /// <param name="mode">The mode with which the items will be added.</param>
+ /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns>
+ bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode mode);
+
+ /// <summary>
+ /// Restarts current item in play queue.
+ /// </summary>
+ void RestartCurrentItem();
+
+ /// <summary>
+ /// Picks next item in play queue.
+ /// </summary>
+ /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns>
+ bool NextItemInQueue();
+
+ /// <summary>
+ /// Picks previous item in play queue.
+ /// </summary>
+ /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns>
+ bool PreviousItemInQueue();
+
+ /// <summary>
+ /// Sets the repeat mode.
+ /// </summary>
+ /// <param name="mode">The new mode.</param>
+ void SetRepeatMode(GroupRepeatMode mode);
+
+ /// <summary>
+ /// Sets the shuffle mode.
+ /// </summary>
+ /// <param name="mode">The new mode.</param>
+ void SetShuffleMode(GroupShuffleMode mode);
+
+ /// <summary>
+ /// Creates a play queue update.
+ /// </summary>
+ /// <param name="reason">The reason for the update.</param>
+ /// <returns>The play queue update.</returns>
+ PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason);
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
deleted file mode 100644
index 60d17fe36..000000000
--- a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
+++ /dev/null
@@ -1,67 +0,0 @@
-using System;
-using System.Threading;
-using MediaBrowser.Controller.Session;
-using MediaBrowser.Model.SyncPlay;
-
-namespace MediaBrowser.Controller.SyncPlay
-{
- /// <summary>
- /// Interface ISyncPlayController.
- /// </summary>
- public interface ISyncPlayController
- {
- /// <summary>
- /// Gets the group id.
- /// </summary>
- /// <value>The group id.</value>
- Guid GetGroupId();
-
- /// <summary>
- /// Gets the playing item id.
- /// </summary>
- /// <value>The playing item id.</value>
- Guid GetPlayingItemId();
-
- /// <summary>
- /// Checks if the group is empty.
- /// </summary>
- /// <value>If the group is empty.</value>
- bool IsGroupEmpty();
-
- /// <summary>
- /// Initializes the group with the session's info.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void CreateGroup(SessionInfo session, CancellationToken cancellationToken);
-
- /// <summary>
- /// Adds the session to the group.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The request.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
-
- /// <summary>
- /// Removes the session from the group.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void SessionLeave(SessionInfo session, CancellationToken cancellationToken);
-
- /// <summary>
- /// Handles the requested action by the session.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="request">The requested action.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
-
- /// <summary>
- /// Gets the info about the group for the clients.
- /// </summary>
- /// <value>The group info for the clients.</value>
- GroupInfoView GetInfo();
- }
-}
diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
index 006fb687b..d0244563a 100644
--- a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
+++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.Requests;
using MediaBrowser.Model.SyncPlay;
namespace MediaBrowser.Controller.SyncPlay
@@ -15,32 +16,33 @@ namespace MediaBrowser.Controller.SyncPlay
/// Creates a new group.
/// </summary>
/// <param name="session">The session that's creating the group.</param>
+ /// <param name="request">The request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- void NewGroup(SessionInfo session, CancellationToken cancellationToken);
+ void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken);
/// <summary>
/// Adds the session to a group.
/// </summary>
/// <param name="session">The session.</param>
- /// <param name="groupId">The group id.</param>
/// <param name="request">The request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken);
+ void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
/// <summary>
/// Removes the session from a group.
/// </summary>
/// <param name="session">The session.</param>
+ /// <param name="request">The request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- void LeaveGroup(SessionInfo session, CancellationToken cancellationToken);
+ void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken);
/// <summary>
/// Gets list of available groups for a session.
/// </summary>
/// <param name="session">The session.</param>
- /// <param name="filterItemId">The item id to filter by.</param>
- /// <value>The list of available groups.</value>
- List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId);
+ /// <param name="request">The request.</param>
+ /// <returns>The list of available groups.</returns>
+ List<GroupInfoDto> ListGroups(SessionInfo session, ListGroupsRequest request);
/// <summary>
/// Handle a request by a session in a group.
@@ -48,22 +50,6 @@ namespace MediaBrowser.Controller.SyncPlay
/// <param name="session">The session.</param>
/// <param name="request">The request.</param>
/// <param name="cancellationToken">The cancellation token.</param>
- void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
-
- /// <summary>
- /// Maps a session to a group.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="group">The group.</param>
- /// <exception cref="InvalidOperationException"></exception>
- void AddSessionToGroup(SessionInfo session, ISyncPlayController group);
-
- /// <summary>
- /// Unmaps a session from a group.
- /// </summary>
- /// <param name="session">The session.</param>
- /// <param name="group">The group.</param>
- /// <exception cref="InvalidOperationException"></exception>
- void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group);
+ void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken);
}
}
diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs
new file mode 100644
index 000000000..bf1981773
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs
@@ -0,0 +1,16 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+ /// <summary>
+ /// Interface ISyncPlayRequest.
+ /// </summary>
+ public interface ISyncPlayRequest
+ {
+ /// <summary>
+ /// Gets the request type.
+ /// </summary>
+ /// <returns>The request type.</returns>
+ RequestType Type { get; }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs
new file mode 100644
index 000000000..4090f65b9
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs
@@ -0,0 +1,29 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class AbstractPlaybackRequest.
+ /// </summary>
+ public abstract class AbstractPlaybackRequest : IGroupPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="AbstractPlaybackRequest"/> class.
+ /// </summary>
+ protected AbstractPlaybackRequest()
+ {
+ // Do nothing.
+ }
+
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.Playback;
+
+ /// <inheritdoc />
+ public abstract PlaybackRequestType Action { get; }
+
+ /// <inheritdoc />
+ public abstract void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken);
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs
new file mode 100644
index 000000000..11cc99fcd
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class BufferGroupRequest.
+ /// </summary>
+ public class BufferGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="BufferGroupRequest"/> class.
+ /// </summary>
+ /// <param name="when">When the request has been made, as reported by the client.</param>
+ /// <param name="positionTicks">The position ticks.</param>
+ /// <param name="isPlaying">Whether the client playback is unpaused.</param>
+ /// <param name="playlistItemId">The playlist item identifier of the playing item.</param>
+ public BufferGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId)
+ {
+ When = when;
+ PositionTicks = positionTicks;
+ IsPlaying = isPlaying;
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets when the request has been made by the client.
+ /// </summary>
+ /// <value>The date of the request.</value>
+ public DateTime When { get; }
+
+ /// <summary>
+ /// Gets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; }
+
+ /// <summary>
+ /// Gets a value indicating whether the client playback is unpaused.
+ /// </summary>
+ /// <value>The client playback status.</value>
+ public bool IsPlaying { get; }
+
+ /// <summary>
+ /// Gets the playlist item identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist item identifier.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Buffer;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs
new file mode 100644
index 000000000..64ef791ed
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class IgnoreWaitGroupRequest.
+ /// </summary>
+ public class IgnoreWaitGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="IgnoreWaitGroupRequest"/> class.
+ /// </summary>
+ /// <param name="ignoreWait">Whether the client should be ignored.</param>
+ public IgnoreWaitGroupRequest(bool ignoreWait)
+ {
+ IgnoreWait = ignoreWait;
+ }
+
+ /// <summary>
+ /// Gets a value indicating whether the client should be ignored.
+ /// </summary>
+ /// <value>The client group-wait status.</value>
+ public bool IgnoreWait { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.IgnoreWait;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs
new file mode 100644
index 000000000..9cd8da566
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class MovePlaylistItemGroupRequest.
+ /// </summary>
+ public class MovePlaylistItemGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="MovePlaylistItemGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playlistItemId">The playlist identifier of the item.</param>
+ /// <param name="newIndex">The new position.</param>
+ public MovePlaylistItemGroupRequest(Guid playlistItemId, int newIndex)
+ {
+ PlaylistItemId = playlistItemId;
+ NewIndex = newIndex;
+ }
+
+ /// <summary>
+ /// Gets the playlist identifier of the item.
+ /// </summary>
+ /// <value>The playlist identifier of the item.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <summary>
+ /// Gets the new position.
+ /// </summary>
+ /// <value>The new position.</value>
+ public int NewIndex { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.MovePlaylistItem;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs
new file mode 100644
index 000000000..e0ae0deb7
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class NextItemGroupRequest.
+ /// </summary>
+ public class NextItemGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NextItemGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playlistItemId">The playing item identifier.</param>
+ public NextItemGroupRequest(Guid playlistItemId)
+ {
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets the playing item identifier.
+ /// </summary>
+ /// <value>The playing item identifier.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.NextItem;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs
new file mode 100644
index 000000000..2869b35f7
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class PauseGroupRequest.
+ /// </summary>
+ public class PauseGroupRequest : AbstractPlaybackRequest
+ {
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Pause;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs
new file mode 100644
index 000000000..8ef3b2030
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class PingGroupRequest.
+ /// </summary>
+ public class PingGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PingGroupRequest"/> class.
+ /// </summary>
+ /// <param name="ping">The ping time.</param>
+ public PingGroupRequest(long ping)
+ {
+ Ping = ping;
+ }
+
+ /// <summary>
+ /// Gets the ping time.
+ /// </summary>
+ /// <value>The ping time.</value>
+ public long Ping { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ping;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs
new file mode 100644
index 000000000..16f9b4087
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class PlayGroupRequest.
+ /// </summary>
+ public class PlayGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playingQueue">The playing queue.</param>
+ /// <param name="playingItemPosition">The playing item position.</param>
+ /// <param name="startPositionTicks">The start position ticks.</param>
+ public PlayGroupRequest(IReadOnlyList<Guid> playingQueue, int playingItemPosition, long startPositionTicks)
+ {
+ PlayingQueue = playingQueue ?? Array.Empty<Guid>();
+ PlayingItemPosition = playingItemPosition;
+ StartPositionTicks = startPositionTicks;
+ }
+
+ /// <summary>
+ /// Gets the playing queue.
+ /// </summary>
+ /// <value>The playing queue.</value>
+ public IReadOnlyList<Guid> PlayingQueue { get; }
+
+ /// <summary>
+ /// Gets the position of the playing item in the queue.
+ /// </summary>
+ /// <value>The playing item position.</value>
+ public int PlayingItemPosition { get; }
+
+ /// <summary>
+ /// Gets the start position ticks.
+ /// </summary>
+ /// <value>The start position ticks.</value>
+ public long StartPositionTicks { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Play;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs
new file mode 100644
index 000000000..166ee0800
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class PreviousItemGroupRequest.
+ /// </summary>
+ public class PreviousItemGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PreviousItemGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playlistItemId">The playing item identifier.</param>
+ public PreviousItemGroupRequest(Guid playlistItemId)
+ {
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets the playing item identifier.
+ /// </summary>
+ /// <value>The playing item identifier.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.PreviousItem;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs
new file mode 100644
index 000000000..d4af63b6d
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class QueueGroupRequest.
+ /// </summary>
+ public class QueueGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="QueueGroupRequest"/> class.
+ /// </summary>
+ /// <param name="items">The items to add to the queue.</param>
+ /// <param name="mode">The enqueue mode.</param>
+ public QueueGroupRequest(IReadOnlyList<Guid> items, GroupQueueMode mode)
+ {
+ ItemIds = items ?? Array.Empty<Guid>();
+ Mode = mode;
+ }
+
+ /// <summary>
+ /// Gets the items to enqueue.
+ /// </summary>
+ /// <value>The items to enqueue.</value>
+ public IReadOnlyList<Guid> ItemIds { get; }
+
+ /// <summary>
+ /// Gets the mode in which to add the new items.
+ /// </summary>
+ /// <value>The enqueue mode.</value>
+ public GroupQueueMode Mode { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Queue;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs
new file mode 100644
index 000000000..74f01cbea
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class ReadyGroupRequest.
+ /// </summary>
+ public class ReadyGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ReadyGroupRequest"/> class.
+ /// </summary>
+ /// <param name="when">When the request has been made, as reported by the client.</param>
+ /// <param name="positionTicks">The position ticks.</param>
+ /// <param name="isPlaying">Whether the client playback is unpaused.</param>
+ /// <param name="playlistItemId">The playlist item identifier of the playing item.</param>
+ public ReadyGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId)
+ {
+ When = when;
+ PositionTicks = positionTicks;
+ IsPlaying = isPlaying;
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets when the request has been made by the client.
+ /// </summary>
+ /// <value>The date of the request.</value>
+ public DateTime When { get; }
+
+ /// <summary>
+ /// Gets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; }
+
+ /// <summary>
+ /// Gets a value indicating whether the client playback is unpaused.
+ /// </summary>
+ /// <value>The client playback status.</value>
+ public bool IsPlaying { get; }
+
+ /// <summary>
+ /// Gets the playlist item identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist item identifier.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ready;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs
new file mode 100644
index 000000000..47c06c222
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class RemoveFromPlaylistGroupRequest.
+ /// </summary>
+ public class RemoveFromPlaylistGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="RemoveFromPlaylistGroupRequest"/> class.
+ /// </summary>
+ /// <param name="items">The playlist ids of the items to remove.</param>
+ public RemoveFromPlaylistGroupRequest(IReadOnlyList<Guid> items)
+ {
+ PlaylistItemIds = items ?? Array.Empty<Guid>();
+ }
+
+ /// <summary>
+ /// Gets the playlist identifiers ot the items.
+ /// </summary>
+ /// <value>The playlist identifiers ot the items.</value>
+ public IReadOnlyList<Guid> PlaylistItemIds { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.RemoveFromPlaylist;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs
new file mode 100644
index 000000000..ecaa689ae
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class SeekGroupRequest.
+ /// </summary>
+ public class SeekGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SeekGroupRequest"/> class.
+ /// </summary>
+ /// <param name="positionTicks">The position ticks.</param>
+ public SeekGroupRequest(long positionTicks)
+ {
+ PositionTicks = positionTicks;
+ }
+
+ /// <summary>
+ /// Gets the position ticks.
+ /// </summary>
+ /// <value>The position ticks.</value>
+ public long PositionTicks { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Seek;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs
new file mode 100644
index 000000000..c3451703e
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class SetPlaylistItemGroupRequest.
+ /// </summary>
+ public class SetPlaylistItemGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SetPlaylistItemGroupRequest"/> class.
+ /// </summary>
+ /// <param name="playlistItemId">The playlist identifier of the item.</param>
+ public SetPlaylistItemGroupRequest(Guid playlistItemId)
+ {
+ PlaylistItemId = playlistItemId;
+ }
+
+ /// <summary>
+ /// Gets the playlist identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist identifier of the playing item.</value>
+ public Guid PlaylistItemId { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetPlaylistItem;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs
new file mode 100644
index 000000000..51011672e
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class SetRepeatModeGroupRequest.
+ /// </summary>
+ public class SetRepeatModeGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SetRepeatModeGroupRequest"/> class.
+ /// </summary>
+ /// <param name="mode">The repeat mode.</param>
+ public SetRepeatModeGroupRequest(GroupRepeatMode mode)
+ {
+ Mode = mode;
+ }
+
+ /// <summary>
+ /// Gets the repeat mode.
+ /// </summary>
+ /// <value>The repeat mode.</value>
+ public GroupRepeatMode Mode { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetRepeatMode;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs
new file mode 100644
index 000000000..d7b2504b4
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class SetShuffleModeGroupRequest.
+ /// </summary>
+ public class SetShuffleModeGroupRequest : AbstractPlaybackRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="SetShuffleModeGroupRequest"/> class.
+ /// </summary>
+ /// <param name="mode">The shuffle mode.</param>
+ public SetShuffleModeGroupRequest(GroupShuffleMode mode)
+ {
+ Mode = mode;
+ }
+
+ /// <summary>
+ /// Gets the shuffle mode.
+ /// </summary>
+ /// <value>The shuffle mode.</value>
+ public GroupShuffleMode Mode { get; }
+
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetShuffleMode;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs
new file mode 100644
index 000000000..ad739213c
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class StopGroupRequest.
+ /// </summary>
+ public class StopGroupRequest : AbstractPlaybackRequest
+ {
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Stop;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs
new file mode 100644
index 000000000..aaf3d65a8
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs
@@ -0,0 +1,21 @@
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests
+{
+ /// <summary>
+ /// Class UnpauseGroupRequest.
+ /// </summary>
+ public class UnpauseGroupRequest : AbstractPlaybackRequest
+ {
+ /// <inheritdoc />
+ public override PlaybackRequestType Action { get; } = PlaybackRequestType.Unpause;
+
+ /// <inheritdoc />
+ public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken)
+ {
+ state.HandleRequest(this, context, state.Type, session, cancellationToken);
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
new file mode 100644
index 000000000..fdec29417
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs
@@ -0,0 +1,577 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Queue
+{
+ /// <summary>
+ /// Class PlayQueueManager.
+ /// </summary>
+ public class PlayQueueManager
+ {
+ /// <summary>
+ /// Placeholder index for when no item is playing.
+ /// </summary>
+ /// <value>The no-playing item index.</value>
+ private const int NoPlayingItemIndex = -1;
+
+ /// <summary>
+ /// Random number generator used to shuffle lists.
+ /// </summary>
+ /// <value>The random number generator.</value>
+ private readonly Random _randomNumberGenerator = new Random();
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayQueueManager" /> class.
+ /// </summary>
+ public PlayQueueManager()
+ {
+ Reset();
+ }
+
+ /// <summary>
+ /// Gets the playing item index.
+ /// </summary>
+ /// <value>The playing item index.</value>
+ public int PlayingItemIndex { get; private set; }
+
+ /// <summary>
+ /// Gets the last time the queue has been changed.
+ /// </summary>
+ /// <value>The last time the queue has been changed.</value>
+ public DateTime LastChange { get; private set; }
+
+ /// <summary>
+ /// Gets the shuffle mode.
+ /// </summary>
+ /// <value>The shuffle mode.</value>
+ public GroupShuffleMode ShuffleMode { get; private set; } = GroupShuffleMode.Sorted;
+
+ /// <summary>
+ /// Gets the repeat mode.
+ /// </summary>
+ /// <value>The repeat mode.</value>
+ public GroupRepeatMode RepeatMode { get; private set; } = GroupRepeatMode.RepeatNone;
+
+ /// <summary>
+ /// Gets or sets the sorted playlist.
+ /// </summary>
+ /// <value>The sorted playlist, or play queue of the group.</value>
+ private List<QueueItem> SortedPlaylist { get; set; } = new List<QueueItem>();
+
+ /// <summary>
+ /// Gets or sets the shuffled playlist.
+ /// </summary>
+ /// <value>The shuffled playlist, or play queue of the group.</value>
+ private List<QueueItem> ShuffledPlaylist { get; set; } = new List<QueueItem>();
+
+ /// <summary>
+ /// Checks if an item is playing.
+ /// </summary>
+ /// <returns><c>true</c> if an item is playing; <c>false</c> otherwise.</returns>
+ public bool IsItemPlaying()
+ {
+ return PlayingItemIndex != NoPlayingItemIndex;
+ }
+
+ /// <summary>
+ /// Gets the current playlist considering the shuffle mode.
+ /// </summary>
+ /// <returns>The playlist.</returns>
+ public IReadOnlyList<QueueItem> GetPlaylist()
+ {
+ return GetPlaylistInternal();
+ }
+
+ /// <summary>
+ /// Sets a new playlist. Playing item is reset.
+ /// </summary>
+ /// <param name="items">The new items of the playlist.</param>
+ public void SetPlaylist(IReadOnlyList<Guid> items)
+ {
+ SortedPlaylist.Clear();
+ ShuffledPlaylist.Clear();
+
+ SortedPlaylist = CreateQueueItemsFromArray(items);
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ ShuffledPlaylist = new List<QueueItem>(SortedPlaylist);
+ Shuffle(ShuffledPlaylist);
+ }
+
+ PlayingItemIndex = NoPlayingItemIndex;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Appends new items to the playlist. The specified order is mantained.
+ /// </summary>
+ /// <param name="items">The items to add to the playlist.</param>
+ public void Queue(IReadOnlyList<Guid> items)
+ {
+ var newItems = CreateQueueItemsFromArray(items);
+
+ SortedPlaylist.AddRange(newItems);
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ ShuffledPlaylist.AddRange(newItems);
+ }
+
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Shuffles the playlist. Shuffle mode is changed. The playlist gets re-shuffled if already shuffled.
+ /// </summary>
+ public void ShufflePlaylist()
+ {
+ if (PlayingItemIndex == NoPlayingItemIndex)
+ {
+ ShuffledPlaylist = new List<QueueItem>(SortedPlaylist);
+ Shuffle(ShuffledPlaylist);
+ }
+ else if (ShuffleMode.Equals(GroupShuffleMode.Sorted))
+ {
+ // First time shuffle.
+ var playingItem = SortedPlaylist[PlayingItemIndex];
+ ShuffledPlaylist = new List<QueueItem>(SortedPlaylist);
+ ShuffledPlaylist.RemoveAt(PlayingItemIndex);
+ Shuffle(ShuffledPlaylist);
+ ShuffledPlaylist.Insert(0, playingItem);
+ PlayingItemIndex = 0;
+ }
+ else
+ {
+ // Re-shuffle playlist.
+ var playingItem = ShuffledPlaylist[PlayingItemIndex];
+ ShuffledPlaylist.RemoveAt(PlayingItemIndex);
+ Shuffle(ShuffledPlaylist);
+ ShuffledPlaylist.Insert(0, playingItem);
+ PlayingItemIndex = 0;
+ }
+
+ ShuffleMode = GroupShuffleMode.Shuffle;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Resets the playlist to sorted mode. Shuffle mode is changed.
+ /// </summary>
+ public void RestoreSortedPlaylist()
+ {
+ if (PlayingItemIndex != NoPlayingItemIndex)
+ {
+ var playingItem = ShuffledPlaylist[PlayingItemIndex];
+ PlayingItemIndex = SortedPlaylist.IndexOf(playingItem);
+ }
+
+ ShuffledPlaylist.Clear();
+
+ ShuffleMode = GroupShuffleMode.Sorted;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Clears the playlist. Shuffle mode is preserved.
+ /// </summary>
+ /// <param name="clearPlayingItem">Whether to remove the playing item as well.</param>
+ public void ClearPlaylist(bool clearPlayingItem)
+ {
+ var playingItem = GetPlayingItem();
+ SortedPlaylist.Clear();
+ ShuffledPlaylist.Clear();
+ LastChange = DateTime.UtcNow;
+
+ if (!clearPlayingItem && playingItem != null)
+ {
+ SortedPlaylist.Add(playingItem);
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ ShuffledPlaylist.Add(playingItem);
+ }
+
+ PlayingItemIndex = 0;
+ }
+ else
+ {
+ PlayingItemIndex = NoPlayingItemIndex;
+ }
+ }
+
+ /// <summary>
+ /// Adds new items to the playlist right after the playing item. The specified order is mantained.
+ /// </summary>
+ /// <param name="items">The items to add to the playlist.</param>
+ public void QueueNext(IReadOnlyList<Guid> items)
+ {
+ var newItems = CreateQueueItemsFromArray(items);
+
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ var playingItem = GetPlayingItem();
+ var sortedPlayingItemIndex = SortedPlaylist.IndexOf(playingItem);
+ // Append items to sorted and shuffled playlist as they are.
+ SortedPlaylist.InsertRange(sortedPlayingItemIndex + 1, newItems);
+ ShuffledPlaylist.InsertRange(PlayingItemIndex + 1, newItems);
+ }
+ else
+ {
+ SortedPlaylist.InsertRange(PlayingItemIndex + 1, newItems);
+ }
+
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Gets playlist identifier of the playing item, if any.
+ /// </summary>
+ /// <returns>The playlist identifier of the playing item.</returns>
+ public Guid GetPlayingItemPlaylistId()
+ {
+ var playingItem = GetPlayingItem();
+ return playingItem?.PlaylistItemId ?? Guid.Empty;
+ }
+
+ /// <summary>
+ /// Gets the playing item identifier, if any.
+ /// </summary>
+ /// <returns>The playing item identifier.</returns>
+ public Guid GetPlayingItemId()
+ {
+ var playingItem = GetPlayingItem();
+ return playingItem?.ItemId ?? Guid.Empty;
+ }
+
+ /// <summary>
+ /// Sets the playing item using its identifier. If not in the playlist, the playing item is reset.
+ /// </summary>
+ /// <param name="itemId">The new playing item identifier.</param>
+ public void SetPlayingItemById(Guid itemId)
+ {
+ var playlist = GetPlaylistInternal();
+ PlayingItemIndex = playlist.FindIndex(item => item.ItemId.Equals(itemId));
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Sets the playing item using its playlist identifier. If not in the playlist, the playing item is reset.
+ /// </summary>
+ /// <param name="playlistItemId">The new playing item identifier.</param>
+ /// <returns><c>true</c> if playing item has been set; <c>false</c> if item is not in the playlist.</returns>
+ public bool SetPlayingItemByPlaylistId(Guid playlistItemId)
+ {
+ var playlist = GetPlaylistInternal();
+ PlayingItemIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId));
+ LastChange = DateTime.UtcNow;
+
+ return PlayingItemIndex != NoPlayingItemIndex;
+ }
+
+ /// <summary>
+ /// Sets the playing item using its position. If not in range, the playing item is reset.
+ /// </summary>
+ /// <param name="playlistIndex">The new playing item index.</param>
+ public void SetPlayingItemByIndex(int playlistIndex)
+ {
+ var playlist = GetPlaylistInternal();
+ if (playlistIndex < 0 || playlistIndex > playlist.Count)
+ {
+ PlayingItemIndex = NoPlayingItemIndex;
+ }
+ else
+ {
+ PlayingItemIndex = playlistIndex;
+ }
+
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Removes items from the playlist. If not removed, the playing item is preserved.
+ /// </summary>
+ /// <param name="playlistItemIds">The items to remove.</param>
+ /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns>
+ public bool RemoveFromPlaylist(IReadOnlyList<Guid> playlistItemIds)
+ {
+ var playingItem = GetPlayingItem();
+
+ SortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
+ ShuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
+
+ LastChange = DateTime.UtcNow;
+
+ if (playingItem != null)
+ {
+ if (playlistItemIds.Contains(playingItem.PlaylistItemId))
+ {
+ // Playing item has been removed, picking previous item.
+ PlayingItemIndex--;
+ if (PlayingItemIndex < 0)
+ {
+ // Was first element, picking next if available.
+ // Default to no playing item otherwise.
+ PlayingItemIndex = SortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex;
+ }
+
+ return true;
+ }
+ else
+ {
+ // Restoring playing item.
+ SetPlayingItemByPlaylistId(playingItem.PlaylistItemId);
+ return false;
+ }
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ /// <summary>
+ /// Moves an item in the playlist to another position.
+ /// </summary>
+ /// <param name="playlistItemId">The item to move.</param>
+ /// <param name="newIndex">The new position.</param>
+ /// <returns><c>true</c> if the item has been moved; <c>false</c> otherwise.</returns>
+ public bool MovePlaylistItem(Guid playlistItemId, int newIndex)
+ {
+ var playlist = GetPlaylistInternal();
+ var playingItem = GetPlayingItem();
+
+ var oldIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId));
+ if (oldIndex < 0)
+ {
+ return false;
+ }
+
+ var queueItem = playlist[oldIndex];
+ playlist.RemoveAt(oldIndex);
+ newIndex = Math.Clamp(newIndex, 0, playlist.Count);
+ playlist.Insert(newIndex, queueItem);
+
+ LastChange = DateTime.UtcNow;
+ PlayingItemIndex = playlist.IndexOf(playingItem);
+ return true;
+ }
+
+ /// <summary>
+ /// Resets the playlist to its initial state.
+ /// </summary>
+ public void Reset()
+ {
+ SortedPlaylist.Clear();
+ ShuffledPlaylist.Clear();
+ PlayingItemIndex = NoPlayingItemIndex;
+ ShuffleMode = GroupShuffleMode.Sorted;
+ RepeatMode = GroupRepeatMode.RepeatNone;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Sets the repeat mode.
+ /// </summary>
+ /// <param name="mode">The new mode.</param>
+ public void SetRepeatMode(GroupRepeatMode mode)
+ {
+ RepeatMode = mode;
+ LastChange = DateTime.UtcNow;
+ }
+
+ /// <summary>
+ /// Sets the shuffle mode.
+ /// </summary>
+ /// <param name="mode">The new mode.</param>
+ public void SetShuffleMode(GroupShuffleMode mode)
+ {
+ if (mode.Equals(GroupShuffleMode.Shuffle))
+ {
+ ShufflePlaylist();
+ }
+ else
+ {
+ RestoreSortedPlaylist();
+ }
+ }
+
+ /// <summary>
+ /// Toggles the shuffle mode between sorted and shuffled.
+ /// </summary>
+ public void ToggleShuffleMode()
+ {
+ if (ShuffleMode.Equals(GroupShuffleMode.Sorted))
+ {
+ ShufflePlaylist();
+ }
+ else
+ {
+ RestoreSortedPlaylist();
+ }
+ }
+
+ /// <summary>
+ /// Gets the next item in the playlist considering repeat mode and shuffle mode.
+ /// </summary>
+ /// <returns>The next item in the playlist.</returns>
+ public QueueItem GetNextItemPlaylistId()
+ {
+ int newIndex;
+ var playlist = GetPlaylistInternal();
+
+ switch (RepeatMode)
+ {
+ case GroupRepeatMode.RepeatOne:
+ newIndex = PlayingItemIndex;
+ break;
+ case GroupRepeatMode.RepeatAll:
+ newIndex = PlayingItemIndex + 1;
+ if (newIndex >= playlist.Count)
+ {
+ newIndex = 0;
+ }
+
+ break;
+ default:
+ newIndex = PlayingItemIndex + 1;
+ break;
+ }
+
+ if (newIndex < 0 || newIndex >= playlist.Count)
+ {
+ return null;
+ }
+
+ return playlist[newIndex];
+ }
+
+ /// <summary>
+ /// Sets the next item in the queue as playing item.
+ /// </summary>
+ /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
+ public bool Next()
+ {
+ if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
+ {
+ LastChange = DateTime.UtcNow;
+ return true;
+ }
+
+ PlayingItemIndex++;
+ if (PlayingItemIndex >= SortedPlaylist.Count)
+ {
+ if (RepeatMode.Equals(GroupRepeatMode.RepeatAll))
+ {
+ PlayingItemIndex = 0;
+ }
+ else
+ {
+ PlayingItemIndex = SortedPlaylist.Count - 1;
+ return false;
+ }
+ }
+
+ LastChange = DateTime.UtcNow;
+ return true;
+ }
+
+ /// <summary>
+ /// Sets the previous item in the queue as playing item.
+ /// </summary>
+ /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
+ public bool Previous()
+ {
+ if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
+ {
+ LastChange = DateTime.UtcNow;
+ return true;
+ }
+
+ PlayingItemIndex--;
+ if (PlayingItemIndex < 0)
+ {
+ if (RepeatMode.Equals(GroupRepeatMode.RepeatAll))
+ {
+ PlayingItemIndex = SortedPlaylist.Count - 1;
+ }
+ else
+ {
+ PlayingItemIndex = 0;
+ return false;
+ }
+ }
+
+ LastChange = DateTime.UtcNow;
+ return true;
+ }
+
+ /// <summary>
+ /// Shuffles a given list.
+ /// </summary>
+ /// <param name="list">The list to shuffle.</param>
+ private void Shuffle<T>(IList<T> list)
+ {
+ int n = list.Count;
+ while (n > 1)
+ {
+ n--;
+ int k = _randomNumberGenerator.Next(n + 1);
+ T value = list[k];
+ list[k] = list[n];
+ list[n] = value;
+ }
+ }
+
+ /// <summary>
+ /// Creates a list from the array of items. Each item is given an unique playlist identifier.
+ /// </summary>
+ /// <returns>The list of queue items.</returns>
+ private List<QueueItem> CreateQueueItemsFromArray(IReadOnlyList<Guid> items)
+ {
+ var list = new List<QueueItem>();
+ foreach (var item in items)
+ {
+ var queueItem = new QueueItem(item);
+ list.Add(queueItem);
+ }
+
+ return list;
+ }
+
+ /// <summary>
+ /// Gets the current playlist considering the shuffle mode.
+ /// </summary>
+ /// <returns>The playlist.</returns>
+ private List<QueueItem> GetPlaylistInternal()
+ {
+ if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ return ShuffledPlaylist;
+ }
+ else
+ {
+ return SortedPlaylist;
+ }
+ }
+
+ /// <summary>
+ /// Gets the current playing item, depending on the shuffle mode.
+ /// </summary>
+ /// <returns>The playing item.</returns>
+ private QueueItem GetPlayingItem()
+ {
+ if (PlayingItemIndex == NoPlayingItemIndex)
+ {
+ return null;
+ }
+ else if (ShuffleMode.Equals(GroupShuffleMode.Shuffle))
+ {
+ return ShuffledPlaylist[PlayingItemIndex];
+ }
+ else
+ {
+ return SortedPlaylist[PlayingItemIndex];
+ }
+ }
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs
new file mode 100644
index 000000000..38c9e8e20
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs
@@ -0,0 +1,29 @@
+using System;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+ /// <summary>
+ /// Class JoinGroupRequest.
+ /// </summary>
+ public class JoinGroupRequest : ISyncPlayRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="JoinGroupRequest"/> class.
+ /// </summary>
+ /// <param name="groupId">The identifier of the group to join.</param>
+ public JoinGroupRequest(Guid groupId)
+ {
+ GroupId = groupId;
+ }
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The identifier of the group to join.</value>
+ public Guid GroupId { get; }
+
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.JoinGroup;
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs
new file mode 100644
index 000000000..545778264
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs
@@ -0,0 +1,13 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+ /// <summary>
+ /// Class LeaveGroupRequest.
+ /// </summary>
+ public class LeaveGroupRequest : ISyncPlayRequest
+ {
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.LeaveGroup;
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs
new file mode 100644
index 000000000..4a234fdab
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs
@@ -0,0 +1,13 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+ /// <summary>
+ /// Class ListGroupsRequest.
+ /// </summary>
+ public class ListGroupsRequest : ISyncPlayRequest
+ {
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.ListGroups;
+ }
+}
diff --git a/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs
new file mode 100644
index 000000000..1321f0de8
--- /dev/null
+++ b/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs
@@ -0,0 +1,28 @@
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay.Requests
+{
+ /// <summary>
+ /// Class NewGroupRequest.
+ /// </summary>
+ public class NewGroupRequest : ISyncPlayRequest
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="NewGroupRequest"/> class.
+ /// </summary>
+ /// <param name="groupName">The name of the new group.</param>
+ public NewGroupRequest(string groupName)
+ {
+ GroupName = groupName;
+ }
+
+ /// <summary>
+ /// Gets the group name.
+ /// </summary>
+ /// <value>The name of the new group.</value>
+ public string GroupName { get; }
+
+ /// <inheritdoc />
+ public RequestType Type { get; } = RequestType.NewGroup;
+ }
+}
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index bc940d0b8..e8aeabf9d 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -87,19 +87,19 @@ namespace MediaBrowser.MediaEncoding.Attachments
MediaAttachment mediaAttachment,
CancellationToken cancellationToken)
{
- var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource.Protocol, mediaAttachment, cancellationToken).ConfigureAwait(false);
+ var attachmentPath = await GetReadableFile(mediaSource.Path, mediaSource.Path, mediaSource, mediaAttachment, cancellationToken).ConfigureAwait(false);
return File.OpenRead(attachmentPath);
}
private async Task<string> GetReadableFile(
string mediaPath,
string inputFile,
- MediaProtocol protocol,
+ MediaSourceInfo mediaSource,
MediaAttachment mediaAttachment,
CancellationToken cancellationToken)
{
- var outputPath = GetAttachmentCachePath(mediaPath, protocol, mediaAttachment.Index);
- await ExtractAttachment(inputFile, protocol, mediaAttachment.Index, outputPath, cancellationToken)
+ var outputPath = GetAttachmentCachePath(mediaPath, mediaSource, mediaAttachment.Index);
+ await ExtractAttachment(inputFile, mediaSource, mediaAttachment.Index, outputPath, cancellationToken)
.ConfigureAwait(false);
return outputPath;
@@ -107,7 +107,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
private async Task ExtractAttachment(
string inputFile,
- MediaProtocol protocol,
+ MediaSourceInfo mediaSource,
int attachmentStreamIndex,
string outputPath,
CancellationToken cancellationToken)
@@ -121,7 +121,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
if (!File.Exists(outputPath))
{
await ExtractAttachmentInternal(
- _mediaEncoder.GetInputArgument(new[] { inputFile }, protocol),
+ _mediaEncoder.GetInputArgument(inputFile, mediaSource),
attachmentStreamIndex,
outputPath,
cancellationToken).ConfigureAwait(false);
@@ -234,10 +234,10 @@ namespace MediaBrowser.MediaEncoding.Attachments
}
}
- private string GetAttachmentCachePath(string mediaPath, MediaProtocol protocol, int attachmentStreamIndex)
+ private string GetAttachmentCachePath(string mediaPath, MediaSourceInfo mediaSource, int attachmentStreamIndex)
{
string filename;
- if (protocol == MediaProtocol.File)
+ if (mediaSource.Protocol == MediaProtocol.File)
{
var date = _fileSystem.GetLastWriteTimeUtc(mediaPath);
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
index 63310fdf6..d0ea0429b 100644
--- a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs
@@ -1,53 +1,44 @@
#pragma warning disable CS1591
using System;
-using System.Collections.Generic;
using System.Globalization;
-using System.Linq;
using MediaBrowser.Model.MediaInfo;
namespace MediaBrowser.MediaEncoding.Encoder
{
public static class EncodingUtils
{
- public static string GetInputArgument(IReadOnlyList<string> inputFiles, MediaProtocol protocol)
+ public static string GetInputArgument(string inputPrefix, string inputFile, MediaProtocol protocol)
{
if (protocol != MediaProtocol.File)
{
- var url = inputFiles[0];
-
- return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", url);
+ return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", inputFile);
}
- return GetConcatInputArgument(inputFiles);
+ return GetConcatInputArgument(inputFile, inputPrefix);
}
/// <summary>
/// Gets the concat input argument.
/// </summary>
- /// <param name="inputFiles">The input files.</param>
+ /// <param name="inputFile">The input file.</param>
+ /// <param name="inputPrefix">The input prefix.</param>
/// <returns>System.String.</returns>
- private static string GetConcatInputArgument(IReadOnlyList<string> inputFiles)
+ private static string GetConcatInputArgument(string inputFile, string inputPrefix)
{
// Get all streams
// If there's more than one we'll need to use the concat command
- if (inputFiles.Count > 1)
- {
- var files = string.Join("|", inputFiles.Select(NormalizePath));
-
- return string.Format(CultureInfo.InvariantCulture, "concat:\"{0}\"", files);
- }
-
// Determine the input path for video files
- return GetFileInputArgument(inputFiles[0]);
+ return GetFileInputArgument(inputFile, inputPrefix);
}
/// <summary>
/// Gets the file input argument.
/// </summary>
/// <param name="path">The path.</param>
+ /// <param name="inputPrefix">The path prefix.</param>
/// <returns>System.String.</returns>
- private static string GetFileInputArgument(string path)
+ private static string GetFileInputArgument(string path, string inputPrefix)
{
if (path.IndexOf("://", StringComparison.Ordinal) != -1)
{
@@ -57,7 +48,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
// Quotes are valid path characters in linux and they need to be escaped here with a leading \
path = NormalizePath(path);
- return string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", path);
+ return string.Format(CultureInfo.InvariantCulture, "{1}:\"{0}\"", path, inputPrefix);
}
/// <summary>
diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
index 5a3a9185d..b1da9c712 100644
--- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs
@@ -18,6 +18,7 @@ using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.MediaEncoding.Probing;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
@@ -34,9 +35,14 @@ namespace MediaBrowser.MediaEncoding.Encoder
public class MediaEncoder : IMediaEncoder, IDisposable
{
/// <summary>
- /// The default image extraction timeout in milliseconds.
+ /// The default SDR image extraction timeout in milliseconds.
/// </summary>
- internal const int DefaultImageExtractionTimeout = 5000;
+ internal const int DefaultSdrImageExtractionTimeout = 10000;
+
+ /// <summary>
+ /// The default HDR image extraction timeout in milliseconds.
+ /// </summary>
+ internal const int DefaultHdrImageExtractionTimeout = 20000;
/// <summary>
/// The us culture.
@@ -64,6 +70,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
private string _ffmpegPath = string.Empty;
private string _ffprobePath;
+ private int threads;
public MediaEncoder(
ILogger<MediaEncoder> logger,
@@ -82,8 +89,6 @@ namespace MediaBrowser.MediaEncoding.Encoder
_jsonSerializerOptions = JsonDefaults.GetOptions();
}
- private EncodingHelper EncodingHelper => _encodingHelperFactory.Value;
-
/// <inheritdoc />
public string EncoderPath => _ffmpegPath;
@@ -129,6 +134,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
SetAvailableDecoders(validator.GetDecoders());
SetAvailableEncoders(validator.GetEncoders());
SetAvailableHwaccels(validator.GetHwaccels());
+ threads = EncodingHelper.GetNumberOfThreads(null, _configurationManager.GetEncodingOptions(), null);
}
_logger.LogInformation("FFmpeg: {EncoderLocation}: {FfmpegPath}", EncoderLocation, _ffmpegPath ?? string.Empty);
@@ -318,33 +324,24 @@ namespace MediaBrowser.MediaEncoding.Encoder
public Task<MediaInfo> GetMediaInfo(MediaInfoRequest request, CancellationToken cancellationToken)
{
var extractChapters = request.MediaType == DlnaProfileType.Video && request.ExtractChapters;
+ var inputFile = request.MediaSource.Path;
- var inputFiles = MediaEncoderHelpers.GetInputArgument(_fileSystem, request.MediaSource.Path, request.MountedIso, request.PlayableStreamFileNames);
-
- var probeSize = EncodingHelper.GetProbeSizeArgument(inputFiles.Length);
- string analyzeDuration;
+ string analyzeDuration = string.Empty;
if (request.MediaSource.AnalyzeDurationMs > 0)
{
analyzeDuration = "-analyzeduration " +
(request.MediaSource.AnalyzeDurationMs * 1000).ToString();
}
- else
- {
- analyzeDuration = EncodingHelper.GetAnalyzeDurationArgument(inputFiles.Length);
- }
-
- probeSize = probeSize + " " + analyzeDuration;
- probeSize = probeSize.Trim();
var forceEnableLogging = request.MediaSource.Protocol != MediaProtocol.File;
return GetMediaInfoInternal(
- GetInputArgument(inputFiles, request.MediaSource.Protocol),
+ GetInputArgument(inputFile, request.MediaSource),
request.MediaSource.Path,
request.MediaSource.Protocol,
extractChapters,
- probeSize,
+ analyzeDuration,
request.MediaType == DlnaProfileType.Audio,
request.MediaSource.VideoType,
forceEnableLogging,
@@ -354,12 +351,20 @@ namespace MediaBrowser.MediaEncoding.Encoder
/// <summary>
/// Gets the input argument.
/// </summary>
- /// <param name="inputFiles">The input files.</param>
- /// <param name="protocol">The protocol.</param>
+ /// <param name="inputFile">The input file.</param>
+ /// <param name="mediaSource">The mediaSource.</param>
/// <returns>System.String.</returns>
/// <exception cref="ArgumentException">Unrecognized InputType.</exception>
- public string GetInputArgument(IReadOnlyList<string> inputFiles, MediaProtocol protocol)
- => EncodingUtils.GetInputArgument(inputFiles, protocol);
+ public string GetInputArgument(string inputFile, MediaSourceInfo mediaSource)
+ {
+ var prefix = "file";
+ if (mediaSource.VideoType == VideoType.BluRay || mediaSource.VideoType == VideoType.Iso)
+ {
+ prefix = "bluray";
+ }
+
+ return EncodingUtils.GetInputArgument(prefix, inputFile, mediaSource.Protocol);
+ }
/// <summary>
/// Gets the media info internal.
@@ -377,9 +382,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
CancellationToken cancellationToken)
{
var args = extractChapters
- ? "{0} -i {1} -threads 0 -v warning -print_format json -show_streams -show_chapters -show_format"
- : "{0} -i {1} -threads 0 -v warning -print_format json -show_streams -show_format";
- args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath).Trim();
+ ? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format"
+ : "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format";
+ args = string.Format(CultureInfo.InvariantCulture, args, probeSizeArgument, inputPath, threads).Trim();
var process = new Process
{
@@ -457,31 +462,36 @@ namespace MediaBrowser.MediaEncoding.Encoder
public Task<string> ExtractAudioImage(string path, int? imageStreamIndex, CancellationToken cancellationToken)
{
- return ExtractImage(new[] { path }, null, null, imageStreamIndex, MediaProtocol.File, true, null, null, cancellationToken);
+ var mediaSource = new MediaSourceInfo
+ {
+ Protocol = MediaProtocol.File
+ };
+
+ return ExtractImage(path, null, null, imageStreamIndex, mediaSource, true, null, null, cancellationToken);
}
- public Task<string> ExtractVideoImage(string[] inputFiles, string container, MediaProtocol protocol, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken)
+ public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream videoStream, Video3DFormat? threedFormat, TimeSpan? offset, CancellationToken cancellationToken)
{
- return ExtractImage(inputFiles, container, videoStream, null, protocol, false, threedFormat, offset, cancellationToken);
+ return ExtractImage(inputFile, container, videoStream, null, mediaSource, false, threedFormat, offset, cancellationToken);
}
- public Task<string> ExtractVideoImage(string[] inputFiles, string container, MediaProtocol protocol, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken)
+ public Task<string> ExtractVideoImage(string inputFile, string container, MediaSourceInfo mediaSource, MediaStream imageStream, int? imageStreamIndex, CancellationToken cancellationToken)
{
- return ExtractImage(inputFiles, container, imageStream, imageStreamIndex, protocol, false, null, null, cancellationToken);
+ return ExtractImage(inputFile, container, imageStream, imageStreamIndex, mediaSource, false, null, null, cancellationToken);
}
private async Task<string> ExtractImage(
- string[] inputFiles,
+ string inputFile,
string container,
MediaStream videoStream,
int? imageStreamIndex,
- MediaProtocol protocol,
+ MediaSourceInfo mediaSource,
bool isAudio,
Video3DFormat? threedFormat,
TimeSpan? offset,
CancellationToken cancellationToken)
{
- var inputArgument = GetInputArgument(inputFiles, protocol);
+ var inputArgument = GetInputArgument(inputFile, mediaSource);
if (isAudio)
{
@@ -493,9 +503,36 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
else
{
+ // The failure of HDR extraction usually occurs when using custom ffmpeg that does not contain the zscale filter.
+ try
+ {
+ return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, true, cancellationToken).ConfigureAwait(false);
+ }
+ catch (ArgumentException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "I-frame or HDR image extraction failed, will attempt with I-frame extraction disabled. Input: {Arguments}", inputArgument);
+ }
+
try
{
- return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, cancellationToken).ConfigureAwait(false);
+ return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, true, cancellationToken).ConfigureAwait(false);
+ }
+ catch (ArgumentException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "HDR image extraction failed, will fallback to SDR image extraction. Input: {Arguments}", inputArgument);
+ }
+
+ try
+ {
+ return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, false, cancellationToken).ConfigureAwait(false);
}
catch (ArgumentException)
{
@@ -507,10 +544,10 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
}
- return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, cancellationToken).ConfigureAwait(false);
+ return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, false, cancellationToken).ConfigureAwait(false);
}
- private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, CancellationToken cancellationToken)
+ private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, bool allowTonemap, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(inputPath))
{
@@ -520,29 +557,29 @@ namespace MediaBrowser.MediaEncoding.Encoder
var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + ".jpg");
Directory.CreateDirectory(Path.GetDirectoryName(tempExtractPath));
- // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar then scale to width 600.
+ // apply some filters to thumbnail extracted below (below) crop any black lines that we made and get the correct ar.
// This filter chain may have adverse effects on recorded tv thumbnails if ar changes during presentation ex. commercials @ diff ar
- var vf = "scale=600:trunc(600/dar/2)*2";
+ var vf = string.Empty;
if (threedFormat.HasValue)
{
switch (threedFormat.Value)
{
case Video3DFormat.HalfSideBySide:
- vf = "crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2";
- // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to 600. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not.
+ vf = "-vf crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1";
+ // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not.
break;
case Video3DFormat.FullSideBySide:
- vf = "crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2";
- // fsbs crop width in half,set the display aspect,crop out any black bars we may have made the scale width to 600.
+ vf = "-vf crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1";
+ // fsbs crop width in half,set the display aspect,crop out any black bars we may have made
break;
case Video3DFormat.HalfTopAndBottom:
- vf = "crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2";
- // htab crop heigh in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to 600
+ vf = "-vf crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1";
+ // htab crop heigh in half,scale to correct size, set the display aspect,crop out any black bars we may have made
break;
case Video3DFormat.FullTopAndBottom:
- vf = "crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale=600:trunc(600/dar/2)*2";
- // ftab crop heigt in half, set the display aspect,crop out any black bars we may have made the scale width to 600
+ vf = "-vf crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1";
+ // ftab crop heigt in half, set the display aspect,crop out any black bars we may have made
break;
default:
break;
@@ -551,15 +588,38 @@ namespace MediaBrowser.MediaEncoding.Encoder
var mapArg = imageStreamIndex.HasValue ? (" -map 0:v:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty;
- var enableThumbnail = !new List<string> { "wtv" }.Contains(container ?? string.Empty, StringComparer.OrdinalIgnoreCase);
+ var enableHdrExtraction = allowTonemap && string.Equals(videoStream?.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase);
+ if (enableHdrExtraction)
+ {
+ string tonemapFilters = "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 (string.IsNullOrEmpty(vf))
+ {
+ vf = "-vf " + tonemapFilters;
+ }
+ else
+ {
+ vf += "," + tonemapFilters;
+ }
+ }
+
// Use ffmpeg to sample 100 (we can drop this if required using thumbnail=50 for 50 frames) frames and pick the best thumbnail. Have a fall back just in case.
- var thumbnail = enableThumbnail ? ",thumbnail=24" : string.Empty;
+ var enableThumbnail = useIFrame && !string.Equals("wtv", container, StringComparison.OrdinalIgnoreCase);
+ if (enableThumbnail)
+ {
+ if (string.IsNullOrEmpty(vf))
+ {
+ vf = "-vf thumbnail=24";
+ }
+ else
+ {
+ vf += ",thumbnail=24";
+ }
+ }
- var args = useIFrame ? string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads 0 -v quiet -vframes 1 -vf \"{2}{4}\" -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, thumbnail) :
- string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads 0 -v quiet -vframes 1 -vf \"{2}\" -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg);
+ var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 {2} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, threads);
- var probeSizeArgument = EncodingHelper.GetProbeSizeArgument(1);
- var analyzeDurationArgument = EncodingHelper.GetAnalyzeDurationArgument(1);
+ var probeSizeArgument = string.Empty;
+ var analyzeDurationArgument = string.Empty;
if (!string.IsNullOrWhiteSpace(probeSizeArgument))
{
@@ -624,7 +684,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
var timeoutMs = _configurationManager.Configuration.ImageExtractionTimeoutMs;
if (timeoutMs <= 0)
{
- timeoutMs = DefaultImageExtractionTimeout;
+ timeoutMs = enableHdrExtraction ? DefaultHdrImageExtractionTimeout : DefaultSdrImageExtractionTimeout;
}
ranToCompletion = await process.WaitForExitAsync(TimeSpan.FromMilliseconds(timeoutMs)).ConfigureAwait(false);
@@ -668,10 +728,10 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
public async Task ExtractVideoImagesOnInterval(
- string[] inputFiles,
+ string inputFile,
string container,
MediaStream videoStream,
- MediaProtocol protocol,
+ MediaSourceInfo mediaSource,
Video3DFormat? threedFormat,
TimeSpan interval,
string targetDirectory,
@@ -679,7 +739,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
int? maxWidth,
CancellationToken cancellationToken)
{
- var inputArgument = GetInputArgument(inputFiles, protocol);
+ var inputArgument = GetInputArgument(inputFile, mediaSource);
var vf = "fps=fps=1/" + interval.TotalSeconds.ToString(_usCulture);
@@ -693,10 +753,10 @@ namespace MediaBrowser.MediaEncoding.Encoder
Directory.CreateDirectory(targetDirectory);
var outputPath = Path.Combine(targetDirectory, filenamePrefix + "%05d.jpg");
- var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads 0 -v quiet -vf \"{2}\" -f image2 \"{1}\"", inputArgument, outputPath, vf);
+ var args = string.Format(CultureInfo.InvariantCulture, "-i {0} -threads {3} -v quiet {2} -f image2 \"{1}\"", inputArgument, outputPath, vf, threads);
- var probeSizeArgument = EncodingHelper.GetProbeSizeArgument(1);
- var analyzeDurationArgument = EncodingHelper.GetAnalyzeDurationArgument(1);
+ var probeSizeArgument = string.Empty;
+ var analyzeDurationArgument = string.Empty;
if (!string.IsNullOrWhiteSpace(probeSizeArgument))
{
@@ -889,16 +949,14 @@ namespace MediaBrowser.MediaEncoding.Encoder
}
/// <inheritdoc />
- public IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, IIsoMount isoMount, uint? titleNumber)
+ public IEnumerable<string> GetPrimaryPlaylistVobFiles(string path, uint? titleNumber)
{
// min size 300 mb
const long MinPlayableSize = 314572800;
- var root = isoMount != null ? isoMount.MountedPath : path;
-
// Try to eliminate menus and intros by skipping all files at the front of the list that are less than the minimum size
// Once we reach a file that is at least the minimum, return all subsequent ones
- var allVobs = _fileSystem.GetFiles(root, true)
+ var allVobs = _fileSystem.GetFiles(path, true)
.Where(file => string.Equals(file.Extension, ".vob", StringComparison.OrdinalIgnoreCase))
.OrderBy(i => i.FullName)
.ToList();
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index 15a70e2e7..bd026bce1 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -234,8 +234,8 @@ namespace MediaBrowser.MediaEncoding.Probing
var channelsValue = channels.Value;
- if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase))
{
if (channelsValue <= 2)
{
@@ -248,6 +248,34 @@ namespace MediaBrowser.MediaEncoding.Probing
}
}
+ if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase))
+ {
+ if (channelsValue <= 2)
+ {
+ return 192000;
+ }
+
+ if (channelsValue >= 5)
+ {
+ return 640000;
+ }
+ }
+
+ if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase))
+ {
+ if (channelsValue <= 2)
+ {
+ return 960000;
+ }
+
+ if (channelsValue >= 5)
+ {
+ return 2880000;
+ }
+ }
+
return null;
}
@@ -666,16 +694,6 @@ namespace MediaBrowser.MediaEncoding.Probing
stream.AverageFrameRate = GetFrameRate(streamInfo.AverageFrameRate);
stream.RealFrameRate = GetFrameRate(streamInfo.RFrameRate);
- // Interlaced video streams in Matroska containers return the field rate instead of the frame rate
- // as both the average and real frame rate, so we half the returned frame rates to get the correct values
- //
- // https://gitlab.com/mbunkus/mkvtoolnix/-/wikis/Wrong-frame-rate-displayed
- if (stream.IsInterlaced && formatInfo.FormatName.Contains("matroska", StringComparison.OrdinalIgnoreCase))
- {
- stream.AverageFrameRate /= 2;
- stream.RealFrameRate /= 2;
- }
-
if (isAudio || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) ||
string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase))
{
@@ -760,7 +778,11 @@ namespace MediaBrowser.MediaEncoding.Probing
}
}
- if (bitrate == 0 && formatInfo != null && !string.IsNullOrEmpty(formatInfo.BitRate) && stream.Type == MediaStreamType.Video)
+ // The bitrate info of FLAC musics and some videos is included in formatInfo.
+ if (bitrate == 0
+ && formatInfo != null
+ && !string.IsNullOrEmpty(formatInfo.BitRate)
+ && (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio)))
{
// If the stream info doesn't have a bitrate get the value from the media format info
if (int.TryParse(formatInfo.BitRate, NumberStyles.Any, _usCulture, out var value))
@@ -774,6 +796,35 @@ namespace MediaBrowser.MediaEncoding.Probing
stream.BitRate = bitrate;
}
+ // Extract bitrate info from tag "BPS" if possible.
+ if (!stream.BitRate.HasValue
+ && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase)))
+ {
+ var bps = GetBPSFromTags(streamInfo);
+ if (bps != null && bps > 0)
+ {
+ stream.BitRate = bps;
+ }
+ }
+
+ // Get average bitrate info from tag "NUMBER_OF_BYTES" and "DURATION" if possible.
+ if (!stream.BitRate.HasValue
+ && (string.Equals(streamInfo.CodecType, "audio", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(streamInfo.CodecType, "video", StringComparison.OrdinalIgnoreCase)))
+ {
+ var durationInSeconds = GetRuntimeSecondsFromTags(streamInfo);
+ var bytes = GetNumberOfBytesFromTags(streamInfo);
+ if (durationInSeconds != null && bytes != null)
+ {
+ var bps = Convert.ToInt32(bytes * 8 / durationInSeconds, CultureInfo.InvariantCulture);
+ if (bps > 0)
+ {
+ stream.BitRate = bps;
+ }
+ }
+ }
+
var disposition = streamInfo.Disposition;
if (disposition != null)
{
@@ -963,6 +1014,50 @@ namespace MediaBrowser.MediaEncoding.Probing
}
}
+ private int? GetBPSFromTags(MediaStreamInfo streamInfo)
+ {
+ if (streamInfo != null && streamInfo.Tags != null)
+ {
+ var bps = GetDictionaryValue(streamInfo.Tags, "BPS-eng") ?? GetDictionaryValue(streamInfo.Tags, "BPS");
+ if (!string.IsNullOrEmpty(bps)
+ && int.TryParse(bps, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBps))
+ {
+ return parsedBps;
+ }
+ }
+
+ return null;
+ }
+
+ private double? GetRuntimeSecondsFromTags(MediaStreamInfo streamInfo)
+ {
+ if (streamInfo != null && streamInfo.Tags != null)
+ {
+ var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION");
+ if (!string.IsNullOrEmpty(duration) && TimeSpan.TryParse(duration, out var parsedDuration))
+ {
+ return parsedDuration.TotalSeconds;
+ }
+ }
+
+ return null;
+ }
+
+ private long? GetNumberOfBytesFromTags(MediaStreamInfo streamInfo)
+ {
+ if (streamInfo != null && streamInfo.Tags != null)
+ {
+ var numberOfBytes = GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES-eng") ?? GetDictionaryValue(streamInfo.Tags, "NUMBER_OF_BYTES");
+ if (!string.IsNullOrEmpty(numberOfBytes)
+ && long.TryParse(numberOfBytes, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedBytes))
+ {
+ return parsedBytes;
+ }
+ }
+
+ return null;
+ }
+
private void SetSize(InternalMediaInfoResult data, MediaInfo info)
{
if (data.Format != null)
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs b/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs
index a5d641747..db6b47583 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SsaParser.cs
@@ -51,7 +51,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
eventsStarted = true;
}
- else if (!string.IsNullOrEmpty(line) && line.Trim().StartsWith(";", StringComparison.Ordinal))
+ else if (!string.IsNullOrEmpty(line) && line.Trim().StartsWith(';'))
{
// skip comment lines
}
@@ -151,13 +151,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
try
{
- var p = new SubtitleTrackEvent();
-
- p.StartPositionTicks = GetTimeCodeFromString(start);
- p.EndPositionTicks = GetTimeCodeFromString(end);
- p.Text = GetFormattedText(text);
-
- trackEvents.Add(p);
+ trackEvents.Add(
+ new SubtitleTrackEvent
+ {
+ StartPositionTicks = GetTimeCodeFromString(start),
+ EndPositionTicks = GetTimeCodeFromString(end),
+ Text = GetFormattedText(text)
+ });
}
catch
{
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 8b3c6b2e6..b92c4ee06 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -168,18 +168,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
MediaStream subtitleStream,
CancellationToken cancellationToken)
{
- string[] inputFiles;
-
- if (mediaSource.VideoType.HasValue
- && (mediaSource.VideoType.Value == VideoType.BluRay || mediaSource.VideoType.Value == VideoType.Dvd))
- {
- var mediaSourceItem = (Video)_libraryManager.GetItemById(new Guid(mediaSource.Id));
- inputFiles = mediaSourceItem.GetPlayableStreamFileNames();
- }
- else
- {
- inputFiles = new[] { mediaSource.Path };
- }
+ var inputFile = mediaSource.Path;
var protocol = mediaSource.Protocol;
if (subtitleStream.IsExternal)
@@ -187,7 +176,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
protocol = _mediaSourceManager.GetPathProtocol(subtitleStream.Path);
}
- var fileInfo = await GetReadableFile(mediaSource.Path, inputFiles, protocol, subtitleStream, cancellationToken).ConfigureAwait(false);
+ var fileInfo = await GetReadableFile(mediaSource.Path, inputFile, mediaSource, subtitleStream, cancellationToken).ConfigureAwait(false);
var stream = await GetSubtitleStream(fileInfo.Path, fileInfo.Protocol, fileInfo.IsExternal, cancellationToken).ConfigureAwait(false);
@@ -220,8 +209,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
private async Task<SubtitleInfo> GetReadableFile(
string mediaPath,
- string[] inputFiles,
- MediaProtocol protocol,
+ string inputFile,
+ MediaSourceInfo mediaSource,
MediaStream subtitleStream,
CancellationToken cancellationToken)
{
@@ -252,9 +241,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
// Extract
- var outputPath = GetSubtitleCachePath(mediaPath, protocol, subtitleStream.Index, "." + outputFormat);
+ var outputPath = GetSubtitleCachePath(mediaPath, mediaSource, subtitleStream.Index, "." + outputFormat);
- await ExtractTextSubtitle(inputFiles, protocol, subtitleStream.Index, outputCodec, outputPath, cancellationToken)
+ await ExtractTextSubtitle(inputFile, mediaSource, subtitleStream.Index, outputCodec, outputPath, cancellationToken)
.ConfigureAwait(false);
return new SubtitleInfo(outputPath, MediaProtocol.File, outputFormat, false);
@@ -266,14 +255,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (GetReader(currentFormat, false) == null)
{
// Convert
- var outputPath = GetSubtitleCachePath(mediaPath, protocol, subtitleStream.Index, ".srt");
+ var outputPath = GetSubtitleCachePath(mediaPath, mediaSource, subtitleStream.Index, ".srt");
- await ConvertTextSubtitleToSrt(subtitleStream.Path, subtitleStream.Language, protocol, outputPath, cancellationToken).ConfigureAwait(false);
+ await ConvertTextSubtitleToSrt(subtitleStream.Path, subtitleStream.Language, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
return new SubtitleInfo(outputPath, MediaProtocol.File, "srt", true);
}
- return new SubtitleInfo(subtitleStream.Path, protocol, currentFormat, true);
+ return new SubtitleInfo(subtitleStream.Path, mediaSource.Protocol, currentFormat, true);
}
private ISubtitleParser GetReader(string format, bool throwIfMissing)
@@ -363,11 +352,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
/// </summary>
/// <param name="inputPath">The input path.</param>
/// <param name="language">The language.</param>
- /// <param name="inputProtocol">The input protocol.</param>
+ /// <param name="mediaSource">The input mediaSource.</param>
/// <param name="outputPath">The output path.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
- private async Task ConvertTextSubtitleToSrt(string inputPath, string language, MediaProtocol inputProtocol, string outputPath, CancellationToken cancellationToken)
+ private async Task ConvertTextSubtitleToSrt(string inputPath, string language, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken)
{
var semaphore = GetLock(outputPath);
@@ -377,7 +366,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
if (!File.Exists(outputPath))
{
- await ConvertTextSubtitleToSrtInternal(inputPath, language, inputProtocol, outputPath, cancellationToken).ConfigureAwait(false);
+ await ConvertTextSubtitleToSrtInternal(inputPath, language, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
}
}
finally
@@ -391,14 +380,14 @@ namespace MediaBrowser.MediaEncoding.Subtitles
/// </summary>
/// <param name="inputPath">The input path.</param>
/// <param name="language">The language.</param>
- /// <param name="inputProtocol">The input protocol.</param>
+ /// <param name="mediaSource">The input mediaSource.</param>
/// <param name="outputPath">The output path.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
/// <exception cref="ArgumentNullException">
/// The <c>inputPath</c> or <c>outputPath</c> is <c>null</c>.
/// </exception>
- private async Task ConvertTextSubtitleToSrtInternal(string inputPath, string language, MediaProtocol inputProtocol, string outputPath, CancellationToken cancellationToken)
+ private async Task ConvertTextSubtitleToSrtInternal(string inputPath, string language, MediaSourceInfo mediaSource, string outputPath, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(inputPath))
{
@@ -412,7 +401,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
- var encodingParam = await GetSubtitleFileCharacterSet(inputPath, language, inputProtocol, cancellationToken).ConfigureAwait(false);
+ var encodingParam = await GetSubtitleFileCharacterSet(inputPath, language, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
// FFmpeg automatically convert character encoding when it is UTF-16
// If we specify character encoding, it rejects with "do not specify a character encoding" and "Unable to recode subtitle event"
@@ -515,8 +504,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
/// <summary>
/// Extracts the text subtitle.
/// </summary>
- /// <param name="inputFiles">The input files.</param>
- /// <param name="protocol">The protocol.</param>
+ /// <param name="inputFile">The input file.</param>
+ /// <param name="mediaSource">The mediaSource.</param>
/// <param name="subtitleStreamIndex">Index of the subtitle stream.</param>
/// <param name="outputCodec">The output codec.</param>
/// <param name="outputPath">The output path.</param>
@@ -524,8 +513,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
/// <returns>Task.</returns>
/// <exception cref="ArgumentException">Must use inputPath list overload.</exception>
private async Task ExtractTextSubtitle(
- string[] inputFiles,
- MediaProtocol protocol,
+ string inputFile,
+ MediaSourceInfo mediaSource,
int subtitleStreamIndex,
string outputCodec,
string outputPath,
@@ -540,7 +529,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (!File.Exists(outputPath))
{
await ExtractTextSubtitleInternal(
- _mediaEncoder.GetInputArgument(inputFiles, protocol),
+ _mediaEncoder.GetInputArgument(inputFile, mediaSource),
subtitleStreamIndex,
outputCodec,
outputPath,
@@ -706,9 +695,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
- private string GetSubtitleCachePath(string mediaPath, MediaProtocol protocol, int subtitleStreamIndex, string outputSubtitleExtension)
+ private string GetSubtitleCachePath(string mediaPath, MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
{
- if (protocol == MediaProtocol.File)
+ if (mediaSource.Protocol == MediaProtocol.File)
{
var ticksParam = string.Empty;
@@ -760,7 +749,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(path), cancellationToken)
.ConfigureAwait(false);
- return await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
}
case MediaProtocol.File:
diff --git a/MediaBrowser.Model/Configuration/EncodingOptions.cs b/MediaBrowser.Model/Configuration/EncodingOptions.cs
index c34825667..100756c24 100644
--- a/MediaBrowser.Model/Configuration/EncodingOptions.cs
+++ b/MediaBrowser.Model/Configuration/EncodingOptions.cs
@@ -67,6 +67,8 @@ namespace MediaBrowser.Model.Configuration
public bool EnableHardwareEncoding { get; set; }
+ public bool AllowHevcEncoding { get; set; }
+
public bool EnableSubtitleExtraction { get; set; }
public string[] HardwareDecodingCodecs { get; set; }
@@ -99,6 +101,7 @@ namespace MediaBrowser.Model.Configuration
EnableDecodingColorDepth10Hevc = true;
EnableDecodingColorDepth10Vp9 = true;
EnableHardwareEncoding = true;
+ AllowHevcEncoding = true;
EnableSubtitleExtraction = true;
HardwareDecodingCodecs = new string[] { "h264", "vc1" };
}
diff --git a/MediaBrowser.Model/Configuration/PathSubstitution.cs b/MediaBrowser.Model/Configuration/PathSubstitution.cs
new file mode 100644
index 000000000..bffaa8594
--- /dev/null
+++ b/MediaBrowser.Model/Configuration/PathSubstitution.cs
@@ -0,0 +1,20 @@
+#nullable enable
+
+namespace MediaBrowser.Model.Configuration
+{
+ /// <summary>
+ /// Defines the <see cref="PathSubstitution" />.
+ /// </summary>
+ public class PathSubstitution
+ {
+ /// <summary>
+ /// Gets or sets the value to substitute.
+ /// </summary>
+ public string From { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the value to substitution with.
+ /// </summary>
+ public string To { get; set; } = string.Empty;
+ }
+}
diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
index 23a5201f7..0dbd51bdc 100644
--- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs
+++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
@@ -1,5 +1,5 @@
-#nullable disable
#pragma warning disable CS1591
+#pragma warning disable CA1819
using System;
using System.Collections.Generic;
@@ -13,43 +13,194 @@ namespace MediaBrowser.Model.Configuration
/// </summary>
public class ServerConfiguration : BaseApplicationConfiguration
{
+ /// <summary>
+ /// The default value for <see cref="HttpServerPortNumber"/>.
+ /// </summary>
public const int DefaultHttpPort = 8096;
+
+ /// <summary>
+ /// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>.
+ /// </summary>
public const int DefaultHttpsPort = 8920;
- private string _baseUrl;
+
+ private string _baseUrl = string.Empty;
+
+ /// <summary>
+ /// Initializes a new instance of the <see cref="ServerConfiguration" /> class.
+ /// </summary>
+ public ServerConfiguration()
+ {
+ MetadataOptions = new[]
+ {
+ new MetadataOptions()
+ {
+ ItemType = "Book"
+ },
+ new MetadataOptions()
+ {
+ ItemType = "Movie"
+ },
+ new MetadataOptions
+ {
+ ItemType = "MusicVideo",
+ DisabledMetadataFetchers = new[] { "The Open Movie Database" },
+ DisabledImageFetchers = new[] { "The Open Movie Database" }
+ },
+ new MetadataOptions
+ {
+ ItemType = "Series",
+ DisabledMetadataFetchers = new[] { "TheMovieDb" },
+ DisabledImageFetchers = new[] { "TheMovieDb" }
+ },
+ new MetadataOptions
+ {
+ ItemType = "MusicAlbum",
+ DisabledMetadataFetchers = new[] { "TheAudioDB" }
+ },
+ new MetadataOptions
+ {
+ ItemType = "MusicArtist",
+ DisabledMetadataFetchers = new[] { "TheAudioDB" }
+ },
+ new MetadataOptions
+ {
+ ItemType = "BoxSet"
+ },
+ new MetadataOptions
+ {
+ ItemType = "Season",
+ DisabledMetadataFetchers = new[] { "TheMovieDb" },
+ },
+ new MetadataOptions
+ {
+ ItemType = "Episode",
+ DisabledMetadataFetchers = new[] { "The Open Movie Database", "TheMovieDb" },
+ DisabledImageFetchers = new[] { "The Open Movie Database", "TheMovieDb" }
+ }
+ };
+ }
/// <summary>
/// Gets or sets a value indicating whether to enable automatic port forwarding.
/// </summary>
- public bool EnableUPnP { get; set; }
+ public bool EnableUPnP { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether to enable prometheus metrics exporting.
/// </summary>
- public bool EnableMetrics { get; set; }
+ public bool EnableMetrics { get; set; } = false;
/// <summary>
/// Gets or sets the public mapped port.
/// </summary>
/// <value>The public mapped port.</value>
- public int PublicPort { get; set; }
+ public int PublicPort { get; set; } = DefaultHttpPort;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding.
+ /// </summary>
+ public bool UPnPCreateHttpPortMap { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets client udp port range.
+ /// </summary>
+ public string UDPPortRange { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether IPV6 capability is enabled.
+ /// </summary>
+ public bool EnableIPV6 { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether IPV4 capability is enabled.
+ /// </summary>
+ public bool EnableIPV4 { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether detailed ssdp logs are sent to the console/log.
+ /// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to work.
+ /// </summary>
+ public bool EnableSSDPTracing { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being sent to the console/log.
+ /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work.
+ /// </summary>
+ public string SSDPTracingFilter { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets the number of times SSDP UDP messages are sent.
+ /// </summary>
+ public int UDPSendCount { get; set; } = 2;
+
+ /// <summary>
+ /// Gets or sets the delay between each groups of SSDP messages (in ms).
+ /// </summary>
+ public int UDPSendDelay { get; set; } = 100;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding.
+ /// </summary>
+ public bool IgnoreVirtualInterfaces { get; set; } = true;
+
+ /// <summary>
+ /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>.
+ /// </summary>
+ public string VirtualInterfaceNames { get; set; } = "vEthernet*";
+
+ /// <summary>
+ /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor.
+ /// </summary>
+ public int GatewayMonitorPeriod { get; set; } = 60;
+
+ /// <summary>
+ /// Gets a value indicating whether multi-socket binding is available.
+ /// </summary>
+ public bool EnableMultiSocketBinding { get; } = true;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network.
+ /// Depending on the address range implemented ULA ranges might not be used.
+ /// </summary>
+ public bool TrustAllIP6Interfaces { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets the ports that HDHomerun uses.
+ /// </summary>
+ public string HDHomerunPortRange { get; set; } = string.Empty;
+
+ /// <summary>
+ /// Gets or sets PublishedServerUri to advertise for specific subnets.
+ /// </summary>
+ public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>();
+
+ /// <summary>
+ /// Gets or sets a value indicating whether Autodiscovery tracing is enabled.
+ /// </summary>
+ public bool AutoDiscoveryTracing { get; set; } = false;
+
+ /// <summary>
+ /// Gets or sets a value indicating whether Autodiscovery is enabled.
+ /// </summary>
+ public bool AutoDiscovery { get; set; } = true;
/// <summary>
/// Gets or sets the public HTTPS port.
/// </summary>
/// <value>The public HTTPS port.</value>
- public int PublicHttpsPort { get; set; }
+ public int PublicHttpsPort { get; set; } = DefaultHttpsPort;
/// <summary>
/// Gets or sets the HTTP server port number.
/// </summary>
/// <value>The HTTP server port number.</value>
- public int HttpServerPortNumber { get; set; }
+ public int HttpServerPortNumber { get; set; } = DefaultHttpPort;
/// <summary>
/// Gets or sets the HTTPS server port number.
/// </summary>
/// <value>The HTTPS server port number.</value>
- public int HttpsPortNumber { get; set; }
+ public int HttpsPortNumber { get; set; } = DefaultHttpsPort;
/// <summary>
/// Gets or sets a value indicating whether to use HTTPS.
@@ -58,19 +209,19 @@ namespace MediaBrowser.Model.Configuration
/// In order for HTTPS to be used, in addition to setting this to true, valid values must also be
/// provided for <see cref="CertificatePath"/> and <see cref="CertificatePassword"/>.
/// </remarks>
- public bool EnableHttps { get; set; }
+ public bool EnableHttps { get; set; } = false;
- public bool EnableNormalizedItemByNameIds { get; set; }
+ public bool EnableNormalizedItemByNameIds { get; set; } = true;
/// <summary>
/// Gets or sets the filesystem path of an X.509 certificate to use for SSL.
/// </summary>
- public string CertificatePath { get; set; }
+ public string CertificatePath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the password required to access the X.509 certificate data in the file specified by <see cref="CertificatePath"/>.
/// </summary>
- public string CertificatePassword { get; set; }
+ public string CertificatePassword { get; set; } = string.Empty;
/// <summary>
/// Gets or sets a value indicating whether this instance is port authorized.
@@ -79,90 +230,93 @@ namespace MediaBrowser.Model.Configuration
public bool IsPortAuthorized { get; set; }
/// <summary>
- /// Gets or sets if quick connect is available for use on this server.
+ /// Gets or sets a value indicating whether quick connect is available for use on this server.
/// </summary>
- public bool QuickConnectAvailable { get; set; }
+ public bool QuickConnectAvailable { get; set; } = false;
- public bool EnableRemoteAccess { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether access outside of the LAN is permitted.
+ /// </summary>
+ public bool EnableRemoteAccess { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether [enable case sensitive item ids].
/// </summary>
/// <value><c>true</c> if [enable case sensitive item ids]; otherwise, <c>false</c>.</value>
- public bool EnableCaseSensitiveItemIds { get; set; }
+ public bool EnableCaseSensitiveItemIds { get; set; } = true;
- public bool DisableLiveTvChannelUserDataName { get; set; }
+ public bool DisableLiveTvChannelUserDataName { get; set; } = true;
/// <summary>
/// Gets or sets the metadata path.
/// </summary>
/// <value>The metadata path.</value>
- public string MetadataPath { get; set; }
+ public string MetadataPath { get; set; } = string.Empty;
- public string MetadataNetworkPath { get; set; }
+ public string MetadataNetworkPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the preferred metadata language.
/// </summary>
/// <value>The preferred metadata language.</value>
- public string PreferredMetadataLanguage { get; set; }
+ public string PreferredMetadataLanguage { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the metadata country code.
/// </summary>
/// <value>The metadata country code.</value>
- public string MetadataCountryCode { get; set; }
+ public string MetadataCountryCode { get; set; } = "US";
/// <summary>
- /// Characters to be replaced with a ' ' in strings to create a sort name.
+ /// Gets or sets characters to be replaced with a ' ' in strings to create a sort name.
/// </summary>
/// <value>The sort replace characters.</value>
- public string[] SortReplaceCharacters { get; set; }
+ public string[] SortReplaceCharacters { get; set; } = new[] { ".", "+", "%" };
/// <summary>
- /// Characters to be removed from strings to create a sort name.
+ /// Gets or sets characters to be removed from strings to create a sort name.
/// </summary>
/// <value>The sort remove characters.</value>
- public string[] SortRemoveCharacters { get; set; }
+ public string[] SortRemoveCharacters { get; set; } = new[] { ",", "&", "-", "{", "}", "'" };
/// <summary>
- /// Words to be removed from strings to create a sort name.
+ /// Gets or sets words to be removed from strings to create a sort name.
/// </summary>
/// <value>The sort remove words.</value>
- public string[] SortRemoveWords { get; set; }
+ public string[] SortRemoveWords { get; set; } = new[] { "the", "a", "an" };
/// <summary>
/// Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated.
/// </summary>
/// <value>The min resume PCT.</value>
- public int MinResumePct { get; set; }
+ public int MinResumePct { get; set; } = 5;
/// <summary>
/// Gets or sets the maximum percentage of an item that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched.
/// </summary>
/// <value>The max resume PCT.</value>
- public int MaxResumePct { get; set; }
+ public int MaxResumePct { get; set; } = 90;
/// <summary>
/// Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates..
/// </summary>
/// <value>The min resume duration seconds.</value>
- public int MinResumeDurationSeconds { get; set; }
+ public int MinResumeDurationSeconds { get; set; } = 300;
/// <summary>
- /// The delay in seconds that we will wait after a file system change to try and discover what has been added/removed
+ /// 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
/// Some delay is necessary with some items because their creation is not atomic. It involves the creation of several
/// different directories and files.
/// </summary>
/// <value>The file watcher delay.</value>
- public int LibraryMonitorDelay { get; set; }
+ public int LibraryMonitorDelay { get; set; } = 60;
/// <summary>
/// Gets or sets a value indicating whether [enable dashboard response caching].
/// Allows potential contributors without visual studio to modify production dashboard code and test changes.
/// </summary>
/// <value><c>true</c> if [enable dashboard response caching]; otherwise, <c>false</c>.</value>
- public bool EnableDashboardResponseCaching { get; set; }
+ public bool EnableDashboardResponseCaching { get; set; } = true;
/// <summary>
/// Gets or sets the image saving convention.
@@ -172,9 +326,9 @@ namespace MediaBrowser.Model.Configuration
public MetadataOptions[] MetadataOptions { get; set; }
- public bool SkipDeserializationForBasicTypes { get; set; }
+ public bool SkipDeserializationForBasicTypes { get; set; } = true;
- public string ServerName { get; set; }
+ public string ServerName { get; set; } = string.Empty;
public string BaseUrl
{
@@ -206,194 +360,94 @@ namespace MediaBrowser.Model.Configuration
}
}
- public string UICulture { get; set; }
+ public string UICulture { get; set; } = "en-US";
- public bool SaveMetadataHidden { get; set; }
+ public bool SaveMetadataHidden { get; set; } = false;
- public NameValuePair[] ContentTypes { get; set; }
+ public NameValuePair[] ContentTypes { get; set; } = Array.Empty<NameValuePair>();
- public int RemoteClientBitrateLimit { get; set; }
+ public int RemoteClientBitrateLimit { get; set; } = 0;
- public bool EnableFolderView { get; set; }
+ public bool EnableFolderView { get; set; } = false;
- public bool EnableGroupingIntoCollections { get; set; }
+ public bool EnableGroupingIntoCollections { get; set; } = false;
- public bool DisplaySpecialsWithinSeasons { get; set; }
+ public bool DisplaySpecialsWithinSeasons { get; set; } = true;
- public string[] LocalNetworkSubnets { get; set; }
-
- public string[] LocalNetworkAddresses { get; set; }
+ /// <summary>
+ /// Gets or sets the subnets that are deemed to make up the LAN.
+ /// </summary>
+ public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>();
- public string[] CodecsUsed { get; set; }
+ /// <summary>
+ /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used.
+ /// </summary>
+ public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>();
- public List<RepositoryInfo> PluginRepositories { get; set; }
+ public string[] CodecsUsed { get; set; } = Array.Empty<string>();
- public bool IgnoreVirtualInterfaces { get; set; }
+ public List<RepositoryInfo> PluginRepositories { get; set; } = new List<RepositoryInfo>();
- public bool EnableExternalContentInSuggestions { get; set; }
+ public bool EnableExternalContentInSuggestions { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether the server should force connections over HTTPS.
/// </summary>
- public bool RequireHttps { get; set; }
+ public bool RequireHttps { get; set; } = false;
- public bool EnableNewOmdbSupport { get; set; }
+ public bool EnableNewOmdbSupport { get; set; } = true;
- public string[] RemoteIPFilter { get; set; }
+ /// <summary>
+ /// Gets or sets the filter for remote IP connectivity. Used in conjuntion with <seealso cref="IsRemoteIPFilterBlacklist"/>.
+ /// </summary>
+ public string[] RemoteIPFilter { get; set; } = Array.Empty<string>();
- public bool IsRemoteIPFilterBlacklist { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist.
+ /// </summary>
+ public bool IsRemoteIPFilterBlacklist { get; set; } = false;
- public int ImageExtractionTimeoutMs { get; set; }
+ public int ImageExtractionTimeoutMs { get; set; } = 0;
- public PathSubstitution[] PathSubstitutions { get; set; }
+ public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty<PathSubstitution>();
- public bool EnableSimpleArtistDetection { get; set; }
+ public bool EnableSimpleArtistDetection { get; set; } = false;
- public string[] UninstalledPlugins { get; set; }
+ public string[] UninstalledPlugins { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets a value indicating whether slow server responses should be logged as a warning.
/// </summary>
- public bool EnableSlowResponseWarning { get; set; }
+ public bool EnableSlowResponseWarning { get; set; } = true;
/// <summary>
/// Gets or sets the threshold for the slow response time warning in ms.
/// </summary>
- public long SlowResponseThresholdMs { get; set; }
+ public long SlowResponseThresholdMs { get; set; } = 500;
/// <summary>
/// Gets or sets the cors hosts.
/// </summary>
- public string[] CorsHosts { get; set; }
+ public string[] CorsHosts { get; set; } = new[] { "*" };
/// <summary>
/// Gets or sets the known proxies.
/// </summary>
- public string[] KnownProxies { get; set; }
+ public string[] KnownProxies { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the number of days we should retain activity logs.
/// </summary>
- public int? ActivityLogRetentionDays { get; set; }
+ public int? ActivityLogRetentionDays { get; set; } = 30;
/// <summary>
- /// Initializes a new instance of the <see cref="ServerConfiguration" /> class.
+ /// Gets or sets the how the library scan fans out.
/// </summary>
- public ServerConfiguration()
- {
- UninstalledPlugins = Array.Empty<string>();
- RemoteIPFilter = Array.Empty<string>();
- LocalNetworkSubnets = Array.Empty<string>();
- LocalNetworkAddresses = Array.Empty<string>();
- CodecsUsed = Array.Empty<string>();
- PathSubstitutions = Array.Empty<PathSubstitution>();
- IgnoreVirtualInterfaces = false;
- EnableSimpleArtistDetection = false;
- SkipDeserializationForBasicTypes = true;
-
- PluginRepositories = new List<RepositoryInfo>();
-
- DisplaySpecialsWithinSeasons = true;
- EnableExternalContentInSuggestions = true;
-
- ImageSavingConvention = ImageSavingConvention.Compatible;
- PublicPort = DefaultHttpPort;
- PublicHttpsPort = DefaultHttpsPort;
- HttpServerPortNumber = DefaultHttpPort;
- HttpsPortNumber = DefaultHttpsPort;
- EnableMetrics = false;
- EnableHttps = false;
- EnableDashboardResponseCaching = true;
- EnableCaseSensitiveItemIds = true;
- EnableNormalizedItemByNameIds = true;
- DisableLiveTvChannelUserDataName = true;
- EnableNewOmdbSupport = true;
-
- EnableRemoteAccess = true;
- QuickConnectAvailable = false;
-
- EnableUPnP = false;
- MinResumePct = 5;
- MaxResumePct = 90;
-
- // 5 minutes
- MinResumeDurationSeconds = 300;
-
- LibraryMonitorDelay = 60;
-
- ContentTypes = Array.Empty<NameValuePair>();
-
- PreferredMetadataLanguage = "en";
- MetadataCountryCode = "US";
-
- SortReplaceCharacters = new[] { ".", "+", "%" };
- SortRemoveCharacters = new[] { ",", "&", "-", "{", "}", "'" };
- SortRemoveWords = new[] { "the", "a", "an" };
-
- BaseUrl = string.Empty;
- UICulture = "en-US";
+ public int LibraryScanFanoutConcurrency { get; set; }
- MetadataOptions = new[]
- {
- new MetadataOptions()
- {
- ItemType = "Book"
- },
- new MetadataOptions()
- {
- ItemType = "Movie"
- },
- new MetadataOptions
- {
- ItemType = "MusicVideo",
- DisabledMetadataFetchers = new[] { "The Open Movie Database" },
- DisabledImageFetchers = new[] { "The Open Movie Database" }
- },
- new MetadataOptions
- {
- ItemType = "Series",
- DisabledMetadataFetchers = new[] { "TheMovieDb" },
- DisabledImageFetchers = new[] { "TheMovieDb" }
- },
- new MetadataOptions
- {
- ItemType = "MusicAlbum",
- DisabledMetadataFetchers = new[] { "TheAudioDB" }
- },
- new MetadataOptions
- {
- ItemType = "MusicArtist",
- DisabledMetadataFetchers = new[] { "TheAudioDB" }
- },
- new MetadataOptions
- {
- ItemType = "BoxSet"
- },
- new MetadataOptions
- {
- ItemType = "Season",
- DisabledMetadataFetchers = new[] { "TheMovieDb" },
- },
- new MetadataOptions
- {
- ItemType = "Episode",
- DisabledMetadataFetchers = new[] { "The Open Movie Database", "TheMovieDb" },
- DisabledImageFetchers = new[] { "The Open Movie Database", "TheMovieDb" }
- }
- };
-
- EnableSlowResponseWarning = true;
- SlowResponseThresholdMs = 500;
- CorsHosts = new[] { "*" };
- KnownProxies = Array.Empty<string>();
- ActivityLogRetentionDays = 30;
- }
- }
-
- public class PathSubstitution
- {
- public string From { get; set; }
-
- public string To { get; set; }
+ /// <summary>
+ /// Gets or sets the how many metadata refreshes can run concurrently.
+ /// </summary>
+ public int LibraryMetadataRefreshConcurrency { get; set; }
}
}
diff --git a/MediaBrowser.Model/Dlna/ContainerProfile.cs b/MediaBrowser.Model/Dlna/ContainerProfile.cs
index 09afa64bb..56c89d854 100644
--- a/MediaBrowser.Model/Dlna/ContainerProfile.cs
+++ b/MediaBrowser.Model/Dlna/ContainerProfile.cs
@@ -47,7 +47,7 @@ namespace MediaBrowser.Model.Dlna
public static bool ContainsContainer(string profileContainers, string inputContainer)
{
var isNegativeList = false;
- if (profileContainers != null && profileContainers.StartsWith("-", StringComparison.Ordinal))
+ if (profileContainers != null && profileContainers.StartsWith('-'))
{
isNegativeList = true;
profileContainers = profileContainers.Substring(1);
diff --git a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs
index a4305c810..65fccbdd4 100644
--- a/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs
+++ b/MediaBrowser.Model/Dlna/ResolutionNormalizer.cs
@@ -15,7 +15,7 @@ namespace MediaBrowser.Model.Dlna
new ResolutionConfiguration(720, 950000),
new ResolutionConfiguration(1280, 2500000),
new ResolutionConfiguration(1920, 4000000),
- new ResolutionConfiguration(2560, 8000000),
+ new ResolutionConfiguration(2560, 20000000),
new ResolutionConfiguration(3840, 35000000)
};
@@ -29,7 +29,7 @@ namespace MediaBrowser.Model.Dlna
int? maxWidth,
int? maxHeight)
{
- // If the bitrate isn't changing, then don't downlscale the resolution
+ // If the bitrate isn't changing, then don't downscale the resolution
if (inputBitrate.HasValue && outputBitrate >= inputBitrate.Value)
{
if (maxWidth.HasValue || maxHeight.HasValue)
@@ -80,11 +80,11 @@ namespace MediaBrowser.Model.Dlna
private static double GetVideoBitrateScaleFactor(string codec)
{
- if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase) ||
- string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(codec, "h265", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "hevc", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(codec, "vp9", StringComparison.OrdinalIgnoreCase))
{
- return .5;
+ return .6;
}
return 1;
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index 43d1f3b44..59c981000 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -872,11 +872,34 @@ namespace MediaBrowser.Model.Dlna
return playlistItem;
}
- private static int GetDefaultAudioBitrateIfUnknown(MediaStream audioStream)
+ private static int GetDefaultAudioBitrate(string audioCodec, int? audioChannels)
{
- if ((audioStream.Channels ?? 0) >= 6)
+ if (!string.IsNullOrEmpty(audioCodec))
{
- return 384000;
+ // Default to a higher bitrate for stream copy
+ if (string.Equals(audioCodec, "aac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "mp3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "ac3", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "eac3", StringComparison.OrdinalIgnoreCase))
+ {
+ if ((audioChannels ?? 0) < 2)
+ {
+ return 128000;
+ }
+
+ return (audioChannels ?? 0) >= 6 ? 640000 : 384000;
+ }
+
+ if (string.Equals(audioCodec, "flac", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(audioCodec, "alac", StringComparison.OrdinalIgnoreCase))
+ {
+ if ((audioChannels ?? 0) < 2)
+ {
+ return 768000;
+ }
+
+ return (audioChannels ?? 0) >= 6 ? 3584000 : 1536000;
+ }
}
return 192000;
@@ -897,14 +920,27 @@ namespace MediaBrowser.Model.Dlna
}
else
{
- if (targetAudioChannels.HasValue && audioStream.Channels.HasValue && targetAudioChannels.Value < audioStream.Channels.Value)
+ if (targetAudioChannels.HasValue
+ && audioStream.Channels.HasValue
+ && audioStream.Channels.Value > targetAudioChannels.Value)
{
- // Reduce the bitrate if we're downmixing
- defaultBitrate = targetAudioChannels.Value < 2 ? 128000 : 192000;
+ // Reduce the bitrate if we're downmixing.
+ defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, targetAudioChannels);
+ }
+ else if (targetAudioChannels.HasValue
+ && audioStream.Channels.HasValue
+ && audioStream.Channels.Value <= targetAudioChannels.Value
+ && !string.IsNullOrEmpty(audioStream.Codec)
+ && targetAudioCodecs != null
+ && targetAudioCodecs.Length > 0
+ && !Array.Exists(targetAudioCodecs, elem => string.Equals(audioStream.Codec, elem, StringComparison.OrdinalIgnoreCase)))
+ {
+ // Shift the bitrate if we're transcoding to a different audio codec.
+ defaultBitrate = GetDefaultAudioBitrate(targetAudioCodec, audioStream.Channels.Value);
}
else
{
- defaultBitrate = audioStream.BitRate ?? GetDefaultAudioBitrateIfUnknown(audioStream);
+ defaultBitrate = audioStream.BitRate ?? GetDefaultAudioBitrate(targetAudioCodec, targetAudioChannels);
}
// Seeing webm encoding failures when source has 1 audio channel and 22k bitrate.
@@ -938,8 +974,28 @@ namespace MediaBrowser.Model.Dlna
{
return 448000;
}
+ else if (totalBitrate <= 4000000)
+ {
+ return 640000;
+ }
+ else if (totalBitrate <= 5000000)
+ {
+ return 768000;
+ }
+ else if (totalBitrate <= 10000000)
+ {
+ return 1536000;
+ }
+ else if (totalBitrate <= 15000000)
+ {
+ return 2304000;
+ }
+ else if (totalBitrate <= 20000000)
+ {
+ return 3584000;
+ }
- return 640000;
+ return 7168000;
}
private (PlayMethod?, List<TranscodeReason>) GetVideoDirectPlayProfile(
diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs
index 93ea82c1c..55b12ae81 100644
--- a/MediaBrowser.Model/Dlna/StreamInfo.cs
+++ b/MediaBrowser.Model/Dlna/StreamInfo.cs
@@ -794,7 +794,7 @@ namespace MediaBrowser.Model.Dlna
public int? GetTargetAudioChannels(string codec)
{
- var defaultValue = GlobalMaxAudioChannels;
+ var defaultValue = GlobalMaxAudioChannels ?? TranscodingMaxAudioChannels;
var value = GetOption(codec, "audiochannels");
if (string.IsNullOrEmpty(value))
diff --git a/MediaBrowser.Model/IO/IIsoManager.cs b/MediaBrowser.Model/IO/IIsoManager.cs
deleted file mode 100644
index 299bb0a21..000000000
--- a/MediaBrowser.Model/IO/IIsoManager.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Model.IO
-{
- public interface IIsoManager
- {
- /// <summary>
- /// Mounts the specified iso path.
- /// </summary>
- /// <param name="isoPath">The iso path.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>IsoMount.</returns>
- /// <exception cref="IOException">Unable to create mount.</exception>
- Task<IIsoMount> Mount(string isoPath, CancellationToken cancellationToken);
-
- /// <summary>
- /// Determines whether this instance can mount the specified path.
- /// </summary>
- /// <param name="path">The path.</param>
- /// <returns><c>true</c> if this instance can mount the specified path; otherwise, <c>false</c>.</returns>
- bool CanMount(string path);
-
- /// <summary>
- /// Adds the parts.
- /// </summary>
- /// <param name="mounters">The mounters.</param>
- void AddParts(IEnumerable<IIsoMounter> mounters);
- }
-}
diff --git a/MediaBrowser.Model/IO/IIsoMount.cs b/MediaBrowser.Model/IO/IIsoMount.cs
deleted file mode 100644
index ea65d976a..000000000
--- a/MediaBrowser.Model/IO/IIsoMount.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using System;
-
-namespace MediaBrowser.Model.IO
-{
- /// <summary>
- /// Interface IIsoMount.
- /// </summary>
- public interface IIsoMount : IDisposable
- {
- /// <summary>
- /// Gets the iso path.
- /// </summary>
- /// <value>The iso path.</value>
- string IsoPath { get; }
-
- /// <summary>
- /// Gets the mounted path.
- /// </summary>
- /// <value>The mounted path.</value>
- string MountedPath { get; }
- }
-}
diff --git a/MediaBrowser.Model/IO/IIsoMounter.cs b/MediaBrowser.Model/IO/IIsoMounter.cs
deleted file mode 100644
index 0d257395a..000000000
--- a/MediaBrowser.Model/IO/IIsoMounter.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.IO;
-using System.Threading;
-using System.Threading.Tasks;
-
-namespace MediaBrowser.Model.IO
-{
- public interface IIsoMounter
- {
- /// <summary>
- /// Gets the name.
- /// </summary>
- /// <value>The name.</value>
- string Name { get; }
-
- /// <summary>
- /// Mounts the specified iso path.
- /// </summary>
- /// <param name="isoPath">The iso path.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>IsoMount.</returns>
- /// <exception cref="ArgumentNullException">isoPath</exception>
- /// <exception cref="IOException">Unable to create mount.</exception>
- Task<IIsoMount> Mount(string isoPath, CancellationToken cancellationToken);
-
- /// <summary>
- /// Determines whether this instance can mount the specified path.
- /// </summary>
- /// <param name="path">The path.</param>
- /// <returns><c>true</c> if this instance can mount the specified path; otherwise, <c>false</c>.</returns>
- bool CanMount(string path);
- }
-}
diff --git a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
index ef435b21e..e8ee49403 100644
--- a/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
+++ b/MediaBrowser.Model/Playlists/PlaylistCreationRequest.cs
@@ -2,6 +2,7 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Generic;
namespace MediaBrowser.Model.Playlists
{
@@ -9,15 +10,10 @@ namespace MediaBrowser.Model.Playlists
{
public string Name { get; set; }
- public Guid[] ItemIdList { get; set; }
+ public IReadOnlyList<Guid> ItemIdList { get; set; } = Array.Empty<Guid>();
public string MediaType { get; set; }
public Guid UserId { get; set; }
-
- public PlaylistCreationRequest()
- {
- ItemIdList = Array.Empty<Guid>();
- }
}
}
diff --git a/MediaBrowser.Model/Querying/NextUpQuery.cs b/MediaBrowser.Model/Querying/NextUpQuery.cs
index ee13ffc16..4ad336d33 100644
--- a/MediaBrowser.Model/Querying/NextUpQuery.cs
+++ b/MediaBrowser.Model/Querying/NextUpQuery.cs
@@ -18,7 +18,7 @@ namespace MediaBrowser.Model.Querying
/// Gets or sets the parent identifier.
/// </summary>
/// <value>The parent identifier.</value>
- public string ParentId { get; set; }
+ public Guid? ParentId { get; set; }
/// <summary>
/// Gets or sets the series id.
diff --git a/MediaBrowser.Model/Search/SearchQuery.cs b/MediaBrowser.Model/Search/SearchQuery.cs
index 01ad319a4..ce60062cd 100644
--- a/MediaBrowser.Model/Search/SearchQuery.cs
+++ b/MediaBrowser.Model/Search/SearchQuery.cs
@@ -47,7 +47,7 @@ namespace MediaBrowser.Model.Search
public string[] ExcludeItemTypes { get; set; }
- public string ParentId { get; set; }
+ public Guid? ParentId { get; set; }
public bool? IsMovie { get; set; }
diff --git a/MediaBrowser.Model/Session/ClientCapabilities.cs b/MediaBrowser.Model/Session/ClientCapabilities.cs
index a85e6ff2a..5852f4e37 100644
--- a/MediaBrowser.Model/Session/ClientCapabilities.cs
+++ b/MediaBrowser.Model/Session/ClientCapabilities.cs
@@ -2,15 +2,16 @@
#pragma warning disable CS1591
using System;
+using System.Collections.Generic;
using MediaBrowser.Model.Dlna;
namespace MediaBrowser.Model.Session
{
public class ClientCapabilities
{
- public string[] PlayableMediaTypes { get; set; }
+ public IReadOnlyList<string> PlayableMediaTypes { get; set; }
- public GeneralCommandType[] SupportedCommands { get; set; }
+ public IReadOnlyList<GeneralCommandType> SupportedCommands { get; set; }
public bool SupportsMediaControl { get; set; }
diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs
new file mode 100644
index 000000000..8c0960b83
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Class GroupInfoDto.
+ /// </summary>
+ public class GroupInfoDto
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GroupInfoDto"/> class.
+ /// </summary>
+ /// <param name="groupId">The group identifier.</param>
+ /// <param name="groupName">The group name.</param>
+ /// <param name="state">The group state.</param>
+ /// <param name="participants">The participants.</param>
+ /// <param name="lastUpdatedAt">The date when this DTO has been created.</param>
+ public GroupInfoDto(Guid groupId, string groupName, GroupStateType state, IReadOnlyList<string> participants, DateTime lastUpdatedAt)
+ {
+ GroupId = groupId;
+ GroupName = groupName;
+ State = state;
+ Participants = participants;
+ LastUpdatedAt = lastUpdatedAt;
+ }
+
+ /// <summary>
+ /// Gets the group identifier.
+ /// </summary>
+ /// <value>The group identifier.</value>
+ public Guid GroupId { get; }
+
+ /// <summary>
+ /// Gets the group name.
+ /// </summary>
+ /// <value>The group name.</value>
+ public string GroupName { get; }
+
+ /// <summary>
+ /// Gets the group state.
+ /// </summary>
+ /// <value>The group state.</value>
+ public GroupStateType State { get; }
+
+ /// <summary>
+ /// Gets the participants.
+ /// </summary>
+ /// <value>The participants.</value>
+ public IReadOnlyList<string> Participants { get; }
+
+ /// <summary>
+ /// Gets the date when this DTO has been created.
+ /// </summary>
+ /// <value>The date when this DTO has been created.</value>
+ public DateTime LastUpdatedAt { get; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs b/MediaBrowser.Model/SyncPlay/GroupInfoView.cs
deleted file mode 100644
index f4c685998..000000000
--- a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-#nullable disable
-
-using System.Collections.Generic;
-
-namespace MediaBrowser.Model.SyncPlay
-{
- /// <summary>
- /// Class GroupInfoView.
- /// </summary>
- public class GroupInfoView
- {
- /// <summary>
- /// Gets or sets the group identifier.
- /// </summary>
- /// <value>The group identifier.</value>
- public string GroupId { get; set; }
-
- /// <summary>
- /// Gets or sets the playing item id.
- /// </summary>
- /// <value>The playing item id.</value>
- public string PlayingItemId { get; set; }
-
- /// <summary>
- /// Gets or sets the playing item name.
- /// </summary>
- /// <value>The playing item name.</value>
- public string PlayingItemName { get; set; }
-
- /// <summary>
- /// Gets or sets the position ticks.
- /// </summary>
- /// <value>The position ticks.</value>
- public long PositionTicks { get; set; }
-
- /// <summary>
- /// Gets or sets the participants.
- /// </summary>
- /// <value>The participants.</value>
- public IReadOnlyList<string> Participants { get; set; }
- }
-}
diff --git a/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs
new file mode 100644
index 000000000..5c9c2627b
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs
@@ -0,0 +1,18 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum GroupQueueMode.
+ /// </summary>
+ public enum GroupQueueMode
+ {
+ /// <summary>
+ /// Insert items at the end of the queue.
+ /// </summary>
+ Queue = 0,
+
+ /// <summary>
+ /// Insert items after the currently playing item.
+ /// </summary>
+ QueueNext = 1
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs
new file mode 100644
index 000000000..4895e57b7
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs
@@ -0,0 +1,23 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum GroupRepeatMode.
+ /// </summary>
+ public enum GroupRepeatMode
+ {
+ /// <summary>
+ /// Repeat one item only.
+ /// </summary>
+ RepeatOne = 0,
+
+ /// <summary>
+ /// Cycle the playlist.
+ /// </summary>
+ RepeatAll = 1,
+
+ /// <summary>
+ /// Do not repeat.
+ /// </summary>
+ RepeatNone = 2
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs
new file mode 100644
index 000000000..de860883c
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs
@@ -0,0 +1,18 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum GroupShuffleMode.
+ /// </summary>
+ public enum GroupShuffleMode
+ {
+ /// <summary>
+ /// Sorted playlist.
+ /// </summary>
+ Sorted = 0,
+
+ /// <summary>
+ /// Shuffled playlist.
+ /// </summary>
+ Shuffle = 1
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupStateType.cs b/MediaBrowser.Model/SyncPlay/GroupStateType.cs
new file mode 100644
index 000000000..7aa454f92
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupStateType.cs
@@ -0,0 +1,28 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum GroupState.
+ /// </summary>
+ public enum GroupStateType
+ {
+ /// <summary>
+ /// The group is in idle state. No media is playing.
+ /// </summary>
+ Idle = 0,
+
+ /// <summary>
+ /// The group is in wating state. Playback is paused. Will start playing when users are ready.
+ /// </summary>
+ Waiting = 1,
+
+ /// <summary>
+ /// The group is in paused state. Playback is paused. Will resume on play command.
+ /// </summary>
+ Paused = 2,
+
+ /// <summary>
+ /// The group is in playing state. Playback is advancing.
+ /// </summary>
+ Playing = 3
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs
new file mode 100644
index 000000000..7f7deb86b
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs
@@ -0,0 +1,31 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Class GroupStateUpdate.
+ /// </summary>
+ public class GroupStateUpdate
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="GroupStateUpdate"/> class.
+ /// </summary>
+ /// <param name="state">The state of the group.</param>
+ /// <param name="reason">The reason of the state change.</param>
+ public GroupStateUpdate(GroupStateType state, PlaybackRequestType reason)
+ {
+ State = state;
+ Reason = reason;
+ }
+
+ /// <summary>
+ /// Gets the state of the group.
+ /// </summary>
+ /// <value>The state of the group.</value>
+ public GroupStateType State { get; }
+
+ /// <summary>
+ /// Gets the reason of the state change.
+ /// </summary>
+ /// <value>The reason of the state change.</value>
+ public PlaybackRequestType Reason { get; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs
index 8c7208211..6f159d653 100644
--- a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs
+++ b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs
@@ -1,28 +1,42 @@
-#nullable disable
+using System;
namespace MediaBrowser.Model.SyncPlay
{
/// <summary>
/// Class GroupUpdate.
/// </summary>
+ /// <typeparam name="T">The type of the data of the message.</typeparam>
public class GroupUpdate<T>
{
/// <summary>
- /// Gets or sets the group identifier.
+ /// Initializes a new instance of the <see cref="GroupUpdate{T}"/> class.
+ /// </summary>
+ /// <param name="groupId">The group identifier.</param>
+ /// <param name="type">The update type.</param>
+ /// <param name="data">The update data.</param>
+ public GroupUpdate(Guid groupId, GroupUpdateType type, T data)
+ {
+ GroupId = groupId;
+ Type = type;
+ Data = data;
+ }
+
+ /// <summary>
+ /// Gets the group identifier.
/// </summary>
/// <value>The group identifier.</value>
- public string GroupId { get; set; }
+ public Guid GroupId { get; }
/// <summary>
- /// Gets or sets the update type.
+ /// Gets the update type.
/// </summary>
/// <value>The update type.</value>
- public GroupUpdateType Type { get; set; }
+ public GroupUpdateType Type { get; }
/// <summary>
- /// Gets or sets the data.
+ /// Gets the update data.
/// </summary>
- /// <value>The data.</value>
- public T Data { get; set; }
+ /// <value>The update data.</value>
+ public T Data { get; }
}
}
diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
index c749f7b13..907d1defe 100644
--- a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
+++ b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
@@ -26,14 +26,14 @@ namespace MediaBrowser.Model.SyncPlay
GroupLeft,
/// <summary>
- /// The group-wait update. Tells members of the group that a user is buffering.
+ /// The group-state update. Tells members of the group that the state changed.
/// </summary>
- GroupWait,
+ StateUpdate,
/// <summary>
- /// The prepare-session update. Tells a user to load some content.
+ /// The play-queue update. Tells a user the playing queue of the group.
/// </summary>
- PrepareSession,
+ PlayQueue,
/// <summary>
/// The not-in-group error. Tells a user that they don't belong to a group.
diff --git a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs b/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
deleted file mode 100644
index 0c77a6132..000000000
--- a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System;
-
-namespace MediaBrowser.Model.SyncPlay
-{
- /// <summary>
- /// Class JoinGroupRequest.
- /// </summary>
- public class JoinGroupRequest
- {
- /// <summary>
- /// Gets or sets the Group id.
- /// </summary>
- /// <value>The Group id to join.</value>
- public Guid GroupId { get; set; }
- }
-}
diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs
new file mode 100644
index 000000000..a851229f7
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Class PlayQueueUpdate.
+ /// </summary>
+ public class PlayQueueUpdate
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="PlayQueueUpdate"/> class.
+ /// </summary>
+ /// <param name="reason">The reason for the update.</param>
+ /// <param name="lastUpdate">The UTC time of the last change to the playing queue.</param>
+ /// <param name="playlist">The playlist.</param>
+ /// <param name="playingItemIndex">The playing item index in the playlist.</param>
+ /// <param name="startPositionTicks">The start position ticks.</param>
+ /// <param name="shuffleMode">The shuffle mode.</param>
+ /// <param name="repeatMode">The repeat mode.</param>
+ public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<QueueItem> playlist, int playingItemIndex, long startPositionTicks, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode)
+ {
+ Reason = reason;
+ LastUpdate = lastUpdate;
+ Playlist = playlist;
+ PlayingItemIndex = playingItemIndex;
+ StartPositionTicks = startPositionTicks;
+ ShuffleMode = shuffleMode;
+ RepeatMode = repeatMode;
+ }
+
+ /// <summary>
+ /// Gets the request type that originated this update.
+ /// </summary>
+ /// <value>The reason for the update.</value>
+ public PlayQueueUpdateReason Reason { get; }
+
+ /// <summary>
+ /// Gets the UTC time of the last change to the playing queue.
+ /// </summary>
+ /// <value>The UTC time of the last change to the playing queue.</value>
+ public DateTime LastUpdate { get; }
+
+ /// <summary>
+ /// Gets the playlist.
+ /// </summary>
+ /// <value>The playlist.</value>
+ public IReadOnlyList<QueueItem> Playlist { get; }
+
+ /// <summary>
+ /// Gets the playing item index in the playlist.
+ /// </summary>
+ /// <value>The playing item index in the playlist.</value>
+ public int PlayingItemIndex { get; }
+
+ /// <summary>
+ /// Gets the start position ticks.
+ /// </summary>
+ /// <value>The start position ticks.</value>
+ public long StartPositionTicks { get; }
+
+ /// <summary>
+ /// Gets the shuffle mode.
+ /// </summary>
+ /// <value>The shuffle mode.</value>
+ public GroupShuffleMode ShuffleMode { get; }
+
+ /// <summary>
+ /// Gets the repeat mode.
+ /// </summary>
+ /// <value>The repeat mode.</value>
+ public GroupRepeatMode RepeatMode { get; }
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs
new file mode 100644
index 000000000..b609f4b1b
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs
@@ -0,0 +1,58 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum PlayQueueUpdateReason.
+ /// </summary>
+ public enum PlayQueueUpdateReason
+ {
+ /// <summary>
+ /// A user is requesting to play a new playlist.
+ /// </summary>
+ NewPlaylist = 0,
+
+ /// <summary>
+ /// A user is changing the playing item.
+ /// </summary>
+ SetCurrentItem = 1,
+
+ /// <summary>
+ /// A user is removing items from the playlist.
+ /// </summary>
+ RemoveItems = 2,
+
+ /// <summary>
+ /// A user is moving an item in the playlist.
+ /// </summary>
+ MoveItem = 3,
+
+ /// <summary>
+ /// A user is adding items the queue.
+ /// </summary>
+ Queue = 4,
+
+ /// <summary>
+ /// A user is adding items to the queue, after the currently playing item.
+ /// </summary>
+ QueueNext = 5,
+
+ /// <summary>
+ /// A user is requesting the next item in queue.
+ /// </summary>
+ NextItem = 6,
+
+ /// <summary>
+ /// A user is requesting the previous item in queue.
+ /// </summary>
+ PreviousItem = 7,
+
+ /// <summary>
+ /// A user is changing repeat mode.
+ /// </summary>
+ RepeatMode = 8,
+
+ /// <summary>
+ /// A user is changing shuffle mode.
+ /// </summary>
+ ShuffleMode = 9
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
deleted file mode 100644
index 9de23194e..000000000
--- a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using System;
-
-namespace MediaBrowser.Model.SyncPlay
-{
- /// <summary>
- /// Class PlaybackRequest.
- /// </summary>
- public class PlaybackRequest
- {
- /// <summary>
- /// Gets or sets the request type.
- /// </summary>
- /// <value>The request type.</value>
- public PlaybackRequestType Type { get; set; }
-
- /// <summary>
- /// Gets or sets when the request has been made by the client.
- /// </summary>
- /// <value>The date of the request.</value>
- public DateTime? When { get; set; }
-
- /// <summary>
- /// Gets or sets the position ticks.
- /// </summary>
- /// <value>The position ticks.</value>
- public long? PositionTicks { get; set; }
-
- /// <summary>
- /// Gets or sets the ping time.
- /// </summary>
- /// <value>The ping time.</value>
- public long? Ping { get; set; }
- }
-}
diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
index e89efeed8..4429623dd 100644
--- a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
+++ b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
@@ -6,33 +6,88 @@ namespace MediaBrowser.Model.SyncPlay
public enum PlaybackRequestType
{
/// <summary>
- /// A user is requesting a play command for the group.
+ /// A user is setting a new playlist.
/// </summary>
Play = 0,
/// <summary>
+ /// A user is changing the playlist item.
+ /// </summary>
+ SetPlaylistItem = 1,
+
+ /// <summary>
+ /// A user is removing items from the playlist.
+ /// </summary>
+ RemoveFromPlaylist = 2,
+
+ /// <summary>
+ /// A user is moving an item in the playlist.
+ /// </summary>
+ MovePlaylistItem = 3,
+
+ /// <summary>
+ /// A user is adding items to the playlist.
+ /// </summary>
+ Queue = 4,
+
+ /// <summary>
+ /// A user is requesting an unpause command for the group.
+ /// </summary>
+ Unpause = 5,
+
+ /// <summary>
/// A user is requesting a pause command for the group.
/// </summary>
- Pause = 1,
+ Pause = 6,
/// <summary>
- /// A user is requesting a seek command for the group.
+ /// A user is requesting a stop command for the group.
/// </summary>
- Seek = 2,
+ Stop = 7,
/// <summary>
+ /// A user is requesting a seek command for the group.
+ /// </summary>
+ Seek = 8,
+
+ /// <summary>
/// A user is signaling that playback is buffering.
/// </summary>
- Buffer = 3,
+ Buffer = 9,
/// <summary>
/// A user is signaling that playback resumed.
/// </summary>
- Ready = 4,
+ Ready = 10,
+
+ /// <summary>
+ /// A user is requesting next item in playlist.
+ /// </summary>
+ NextItem = 11,
+
+ /// <summary>
+ /// A user is requesting previous item in playlist.
+ /// </summary>
+ PreviousItem = 12,
+
+ /// <summary>
+ /// A user is setting the repeat mode.
+ /// </summary>
+ SetRepeatMode = 13,
+
+ /// <summary>
+ /// A user is setting the shuffle mode.
+ /// </summary>
+ SetShuffleMode = 14,
+
+ /// <summary>
+ /// A user is reporting their ping.
+ /// </summary>
+ Ping = 15,
/// <summary>
- /// A user is reporting its ping.
+ /// A user is requesting to be ignored on group wait.
/// </summary>
- Ping = 5
+ IgnoreWait = 16
}
}
diff --git a/MediaBrowser.Model/SyncPlay/QueueItem.cs b/MediaBrowser.Model/SyncPlay/QueueItem.cs
new file mode 100644
index 000000000..a6dcc109e
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/QueueItem.cs
@@ -0,0 +1,31 @@
+using System;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Class QueueItem.
+ /// </summary>
+ public class QueueItem
+ {
+ /// <summary>
+ /// Initializes a new instance of the <see cref="QueueItem"/> class.
+ /// </summary>
+ /// <param name="itemId">The item identifier.</param>
+ public QueueItem(Guid itemId)
+ {
+ ItemId = itemId;
+ }
+
+ /// <summary>
+ /// Gets the item identifier.
+ /// </summary>
+ /// <value>The item identifier.</value>
+ public Guid ItemId { get; }
+
+ /// <summary>
+ /// Gets the playlist identifier of the item.
+ /// </summary>
+ /// <value>The playlist identifier of the item.</value>
+ public Guid PlaylistItemId { get; } = Guid.NewGuid();
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/RequestType.cs b/MediaBrowser.Model/SyncPlay/RequestType.cs
new file mode 100644
index 000000000..a6e397dcd
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/RequestType.cs
@@ -0,0 +1,33 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Enum RequestType.
+ /// </summary>
+ public enum RequestType
+ {
+ /// <summary>
+ /// A user is requesting to create a new group.
+ /// </summary>
+ NewGroup = 0,
+
+ /// <summary>
+ /// A user is requesting to join a group.
+ /// </summary>
+ JoinGroup = 1,
+
+ /// <summary>
+ /// A user is requesting to leave a group.
+ /// </summary>
+ LeaveGroup = 2,
+
+ /// <summary>
+ /// A user is requesting the list of available groups.
+ /// </summary>
+ ListGroups = 3,
+
+ /// <summary>
+ /// A user is sending a playback command to a group.
+ /// </summary>
+ Playback = 4
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/SendCommand.cs b/MediaBrowser.Model/SyncPlay/SendCommand.cs
index 0f0be0152..73cb50876 100644
--- a/MediaBrowser.Model/SyncPlay/SendCommand.cs
+++ b/MediaBrowser.Model/SyncPlay/SendCommand.cs
@@ -1,4 +1,4 @@
-#nullable disable
+using System;
namespace MediaBrowser.Model.SyncPlay
{
@@ -8,33 +8,58 @@ namespace MediaBrowser.Model.SyncPlay
public class SendCommand
{
/// <summary>
- /// Gets or sets the group identifier.
+ /// Initializes a new instance of the <see cref="SendCommand"/> class.
+ /// </summary>
+ /// <param name="groupId">The group identifier.</param>
+ /// <param name="playlistItemId">The playlist identifier of the playing item.</param>
+ /// <param name="when">The UTC time when to execute the command.</param>
+ /// <param name="command">The command.</param>
+ /// <param name="positionTicks">The position ticks, for commands that require it.</param>
+ /// <param name="emittedAt">The UTC time when this command has been emitted.</param>
+ public SendCommand(Guid groupId, Guid playlistItemId, DateTime when, SendCommandType command, long? positionTicks, DateTime emittedAt)
+ {
+ GroupId = groupId;
+ PlaylistItemId = playlistItemId;
+ When = when;
+ Command = command;
+ PositionTicks = positionTicks;
+ EmittedAt = emittedAt;
+ }
+
+ /// <summary>
+ /// Gets the group identifier.
/// </summary>
/// <value>The group identifier.</value>
- public string GroupId { get; set; }
+ public Guid GroupId { get; }
+
+ /// <summary>
+ /// Gets the playlist identifier of the playing item.
+ /// </summary>
+ /// <value>The playlist identifier of the playing item.</value>
+ public Guid PlaylistItemId { get; }
/// <summary>
/// Gets or sets the UTC time when to execute the command.
/// </summary>
/// <value>The UTC time when to execute the command.</value>
- public string When { get; set; }
+ public DateTime When { get; set; }
/// <summary>
- /// Gets or sets the position ticks.
+ /// Gets the position ticks.
/// </summary>
/// <value>The position ticks.</value>
- public long? PositionTicks { get; set; }
+ public long? PositionTicks { get; }
/// <summary>
- /// Gets or sets the command.
+ /// Gets the command.
/// </summary>
/// <value>The command.</value>
- public SendCommandType Command { get; set; }
+ public SendCommandType Command { get; }
/// <summary>
- /// Gets or sets the UTC time when this command has been emitted.
+ /// Gets the UTC time when this command has been emitted.
/// </summary>
/// <value>The UTC time when this command has been emitted.</value>
- public string EmittedAt { get; set; }
+ public DateTime EmittedAt { get; }
}
}
diff --git a/MediaBrowser.Model/SyncPlay/SendCommandType.cs b/MediaBrowser.Model/SyncPlay/SendCommandType.cs
index 86dec9e90..e6b17c60a 100644
--- a/MediaBrowser.Model/SyncPlay/SendCommandType.cs
+++ b/MediaBrowser.Model/SyncPlay/SendCommandType.cs
@@ -6,9 +6,9 @@ namespace MediaBrowser.Model.SyncPlay
public enum SendCommandType
{
/// <summary>
- /// The play command. Instructs users to start playback.
+ /// The unpause command. Instructs users to unpause playback.
/// </summary>
- Play = 0,
+ Unpause = 0,
/// <summary>
/// The pause command. Instructs users to pause playback.
@@ -16,8 +16,13 @@ namespace MediaBrowser.Model.SyncPlay
Pause = 1,
/// <summary>
+ /// The stop command. Instructs users to stop playback.
+ /// </summary>
+ Stop = 2,
+
+ /// <summary>
/// The seek command. Instructs users to seek to a specified time.
/// </summary>
- Seek = 2
+ Seek = 3
}
}
diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs
new file mode 100644
index 000000000..29dbb11b3
--- /dev/null
+++ b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs
@@ -0,0 +1,28 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+ /// <summary>
+ /// Used to filter the sessions of a group.
+ /// </summary>
+ public enum SyncPlayBroadcastType
+ {
+ /// <summary>
+ /// All sessions will receive the message.
+ /// </summary>
+ AllGroup = 0,
+
+ /// <summary>
+ /// Only the specified session will receive the message.
+ /// </summary>
+ CurrentSession = 1,
+
+ /// <summary>
+ /// All sessions, except the current one, will receive the message.
+ /// </summary>
+ AllExceptCurrentSession = 2,
+
+ /// <summary>
+ /// Only sessions that are not buffering will receive the message.
+ /// </summary>
+ AllReady = 3
+ }
+}
diff --git a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
index 8ec5eaab3..219e7b1e0 100644
--- a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
+++ b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
@@ -1,4 +1,4 @@
-#nullable disable
+using System;
namespace MediaBrowser.Model.SyncPlay
{
@@ -8,15 +8,26 @@ namespace MediaBrowser.Model.SyncPlay
public class UtcTimeResponse
{
/// <summary>
- /// Gets or sets the UTC time when request has been received.
+ /// Initializes a new instance of the <see cref="UtcTimeResponse"/> class.
+ /// </summary>
+ /// <param name="requestReceptionTime">The UTC time when request has been received.</param>
+ /// <param name="responseTransmissionTime">The UTC time when response has been sent.</param>
+ public UtcTimeResponse(DateTime requestReceptionTime, DateTime responseTransmissionTime)
+ {
+ RequestReceptionTime = requestReceptionTime;
+ ResponseTransmissionTime = responseTransmissionTime;
+ }
+
+ /// <summary>
+ /// Gets the UTC time when request has been received.
/// </summary>
/// <value>The UTC time when request has been received.</value>
- public string RequestReceptionTime { get; set; }
+ public DateTime RequestReceptionTime { get; }
/// <summary>
- /// Gets or sets the UTC time when response has been sent.
+ /// Gets the UTC time when response has been sent.
/// </summary>
/// <value>The UTC time when response has been sent.</value>
- public string ResponseTransmissionTime { get; set; }
+ public DateTime ResponseTransmissionTime { get; }
}
}
diff --git a/MediaBrowser.Model/Updates/PackageInfo.cs b/MediaBrowser.Model/Updates/PackageInfo.cs
index 98b151d55..5e9304363 100644
--- a/MediaBrowser.Model/Updates/PackageInfo.cs
+++ b/MediaBrowser.Model/Updates/PackageInfo.cs
@@ -50,17 +50,7 @@ namespace MediaBrowser.Model.Updates
/// Gets or sets the versions.
/// </summary>
/// <value>The versions.</value>
- public IReadOnlyList<VersionInfo> versions { get; set; }
-
- /// <summary>
- /// Gets or sets the repository name.
- /// </summary>
- public string repositoryName { get; set; }
-
- /// <summary>
- /// Gets or sets the repository url.
- /// </summary>
- public string repositoryUrl { get; set; }
+ public IList<VersionInfo> versions { get; set; }
/// <summary>
/// Initializes a new instance of the <see cref="PackageInfo"/> class.
diff --git a/MediaBrowser.Model/Updates/RepositoryInfo.cs b/MediaBrowser.Model/Updates/RepositoryInfo.cs
index bd42e77f0..705d3b33c 100644
--- a/MediaBrowser.Model/Updates/RepositoryInfo.cs
+++ b/MediaBrowser.Model/Updates/RepositoryInfo.cs
@@ -16,5 +16,11 @@ namespace MediaBrowser.Model.Updates
/// </summary>
/// <value>The URL.</value>
public string? Url { get; set; }
+
+ /// <summary>
+ /// Gets or sets a value indicating whether the repository is enabled.
+ /// </summary>
+ /// <value><c>true</c> if enabled.</value>
+ public bool Enabled { get; set; } = true;
}
}
diff --git a/MediaBrowser.Model/Updates/VersionInfo.cs b/MediaBrowser.Model/Updates/VersionInfo.cs
index a4aa0e75f..844170999 100644
--- a/MediaBrowser.Model/Updates/VersionInfo.cs
+++ b/MediaBrowser.Model/Updates/VersionInfo.cs
@@ -9,11 +9,29 @@ namespace MediaBrowser.Model.Updates
/// </summary>
public class VersionInfo
{
+ private Version _version;
+
/// <summary>
/// Gets or sets the version.
/// </summary>
/// <value>The version.</value>
- public string version { get; set; }
+ public string version
+ {
+ get
+ {
+ return _version == null ? string.Empty : _version.ToString();
+ }
+
+ set
+ {
+ _version = Version.Parse(value);
+ }
+ }
+
+ /// <summary>
+ /// Gets the version as a <see cref="Version"/>.
+ /// </summary>
+ public Version VersionNumber => _version;
/// <summary>
/// Gets or sets the changelog for this version.
@@ -44,5 +62,15 @@ namespace MediaBrowser.Model.Updates
/// </summary>
/// <value>The timestamp.</value>
public string timestamp { get; set; }
+
+ /// <summary>
+ /// Gets or sets the repository name.
+ /// </summary>
+ public string repositoryName { get; set; }
+
+ /// <summary>
+ /// Gets or sets the repository url.
+ /// </summary>
+ public string repositoryUrl { get; set; }
}
}
diff --git a/MediaBrowser.Providers/Manager/ItemImageProvider.cs b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
index 39748171a..ffc6889fa 100644
--- a/MediaBrowser.Providers/Manager/ItemImageProvider.cs
+++ b/MediaBrowser.Providers/Manager/ItemImageProvider.cs
@@ -469,7 +469,7 @@ namespace MediaBrowser.Providers.Manager
try
{
using var response = await provider.GetImageResponse(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await _providerManager.SaveImage(
item,
@@ -586,7 +586,7 @@ namespace MediaBrowser.Providers.Manager
}
}
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await _providerManager.SaveImage(
item,
stream,
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index dca8acb7d..6dbce3067 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -229,16 +229,16 @@ namespace MediaBrowser.Providers.Manager
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
}
- private async Task SavePeopleMetadataAsync(List<PersonInfo> people, LibraryOptions libraryOptions, CancellationToken cancellationToken)
+ private Task SavePeopleMetadataAsync(List<PersonInfo> people, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{
+ var personsToSave = new List<BaseItem>();
+
foreach (var person in people)
{
cancellationToken.ThrowIfCancellationRequested();
if (person.ProviderIds.Count > 0 || !string.IsNullOrWhiteSpace(person.ImageUrl))
{
- var updateType = ItemUpdateType.MetadataDownload;
-
var saveEntity = false;
var personEntity = LibraryManager.GetPerson(person.Name);
foreach (var id in person.ProviderIds)
@@ -261,15 +261,18 @@ namespace MediaBrowser.Providers.Manager
0);
saveEntity = true;
- updateType |= ItemUpdateType.ImageUpdate;
}
if (saveEntity)
{
- await personEntity.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false);
+ personsToSave.Add(personEntity);
}
}
}
+
+ LibraryManager.RunMetadataSavers(personsToSave, ItemUpdateType.MetadataDownload);
+ LibraryManager.CreateItems(personsToSave, null, CancellationToken.None);
+ return Task.CompletedTask;
}
protected virtual Task AfterMetadataRefresh(TItemType item, MetadataRefreshOptions refreshOptions, CancellationToken cancellationToken)
diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs
index 7a1b7bb2c..a20c47cf2 100644
--- a/MediaBrowser.Providers/Manager/ProviderManager.cs
+++ b/MediaBrowser.Providers/Manager/ProviderManager.cs
@@ -13,6 +13,7 @@ using Jellyfin.Data.Events;
using MediaBrowser.Common.Net;
using MediaBrowser.Common.Progress;
using MediaBrowser.Controller;
+using MediaBrowser.Controller.BaseItemManager;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
@@ -51,6 +52,7 @@ namespace MediaBrowser.Providers.Manager
private readonly ILibraryManager _libraryManager;
private readonly ISubtitleManager _subtitleManager;
private readonly IServerConfigurationManager _configurationManager;
+ private readonly IBaseItemManager _baseItemManager;
private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new ConcurrentDictionary<Guid, double>();
private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource();
private readonly SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>> _refreshQueue =
@@ -74,6 +76,7 @@ namespace MediaBrowser.Providers.Manager
/// <param name="fileSystem">The filesystem.</param>
/// <param name="appPaths">The server application paths.</param>
/// <param name="libraryManager">The library manager.</param>
+ /// <param name="baseItemManager">The BaseItem manager.</param>
public ProviderManager(
IHttpClientFactory httpClientFactory,
ISubtitleManager subtitleManager,
@@ -82,7 +85,8 @@ namespace MediaBrowser.Providers.Manager
ILogger<ProviderManager> logger,
IFileSystem fileSystem,
IServerApplicationPaths appPaths,
- ILibraryManager libraryManager)
+ ILibraryManager libraryManager,
+ IBaseItemManager baseItemManager)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
@@ -92,6 +96,7 @@ namespace MediaBrowser.Providers.Manager
_appPaths = appPaths;
_libraryManager = libraryManager;
_subtitleManager = subtitleManager;
+ _baseItemManager = baseItemManager;
}
/// <inheritdoc/>
@@ -181,7 +186,7 @@ namespace MediaBrowser.Providers.Manager
throw new HttpRequestException("Invalid image received.", null, HttpStatusCode.NotFound);
}
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await SaveImage(
item,
stream,
@@ -392,7 +397,7 @@ namespace MediaBrowser.Providers.Manager
if (provider is IRemoteMetadataProvider)
{
- if (!forceEnableInternetMetadata && !item.IsMetadataFetcherEnabled(libraryOptions, provider.Name))
+ if (!forceEnableInternetMetadata && !_baseItemManager.IsMetadataFetcherEnabled(item, libraryOptions, provider.Name))
{
return false;
}
@@ -436,7 +441,7 @@ namespace MediaBrowser.Providers.Manager
if (provider is IRemoteImageProvider || provider is IDynamicImageProvider)
{
- if (!item.IsImageFetcherEnabled(libraryOptions, provider.Name))
+ if (!_baseItemManager.IsImageFetcherEnabled(item, libraryOptions, provider.Name))
{
return false;
}
@@ -1162,6 +1167,29 @@ namespace MediaBrowser.Providers.Manager
return RefreshItem(item, options, cancellationToken);
}
+ /// <summary>
+ /// Runs multiple metadata refreshes concurrently.
+ /// </summary>
+ /// <param name="action">The action to run.</param>
+ /// <param name="cancellationToken">The cancellation token.</param>
+ /// <returns>A <see cref="Task"/> representing the result of the asynchronous operation.</returns>
+ public async Task RunMetadataRefresh(Func<Task> action, CancellationToken cancellationToken)
+ {
+ // create a variable for this since it is possible MetadataRefreshThrottler could change due to a config update during a scan
+ var metadataRefreshThrottler = _baseItemManager.MetadataRefreshThrottler;
+
+ await metadataRefreshThrottler.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ await action().ConfigureAwait(false);
+ }
+ finally
+ {
+ metadataRefreshThrottler.Release();
+ }
+ }
+
/// <inheritdoc/>
public void Dispose()
{
diff --git a/MediaBrowser.Providers/Manager/ProviderUtils.cs b/MediaBrowser.Providers/Manager/ProviderUtils.cs
index 70a5a6ac1..5621d2b86 100644
--- a/MediaBrowser.Providers/Manager/ProviderUtils.cs
+++ b/MediaBrowser.Providers/Manager/ProviderUtils.cs
@@ -38,7 +38,7 @@ namespace MediaBrowser.Providers.Manager
{
if (replaceData || string.IsNullOrEmpty(target.Name))
{
- // Safeguard against incoming data having an emtpy name
+ // Safeguard against incoming data having an empty name
if (!string.IsNullOrWhiteSpace(source.Name))
{
target.Name = source.Name;
@@ -48,7 +48,7 @@ namespace MediaBrowser.Providers.Manager
if (replaceData || string.IsNullOrEmpty(target.OriginalTitle))
{
- // Safeguard against incoming data having an emtpy name
+ // Safeguard against incoming data having an empty name
if (!string.IsNullOrWhiteSpace(source.OriginalTitle))
{
target.OriginalTitle = source.OriginalTitle;
diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs
index c61187fdf..4fff57273 100644
--- a/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/FFProbeProvider.cs
@@ -150,11 +150,6 @@ namespace MediaBrowser.Providers.MediaInfo
public Task<ItemUpdateType> FetchVideoInfo<T>(T item, MetadataRefreshOptions options, CancellationToken cancellationToken)
where T : Video
{
- if (item.VideoType == VideoType.Iso)
- {
- return _cachedTask;
- }
-
if (item.IsPlaceHolder)
{
return _cachedTask;
@@ -208,7 +203,7 @@ namespace MediaBrowser.Providers.MediaInfo
{
item.ShortcutPath = File.ReadAllLines(item.Path)
.Select(NormalizeStrmLine)
- .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith("#", StringComparison.OrdinalIgnoreCase));
+ .FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#'));
}
public Task<ItemUpdateType> FetchAudioInfo<T>(T item, MetadataRefreshOptions options, CancellationToken cancellationToken)
diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
index 776dee780..74849a522 100644
--- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
+++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs
@@ -116,7 +116,7 @@ namespace MediaBrowser.Providers.MediaInfo
streamFileNames = Array.Empty<string>();
}
- mediaInfoResult = await GetMediaInfo(item, streamFileNames, cancellationToken).ConfigureAwait(false);
+ mediaInfoResult = await GetMediaInfo(item, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
}
@@ -128,7 +128,6 @@ namespace MediaBrowser.Providers.MediaInfo
private Task<Model.MediaInfo.MediaInfo> GetMediaInfo(
Video item,
- string[] streamFileNames,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -145,7 +144,6 @@ namespace MediaBrowser.Providers.MediaInfo
return _mediaEncoder.GetMediaInfo(
new MediaInfoRequest
{
- PlayableStreamFileNames = streamFileNames,
ExtractChapters = true,
MediaType = DlnaProfileType.Video,
MediaSource = new MediaSourceInfo
@@ -621,7 +619,7 @@ namespace MediaBrowser.Providers.MediaInfo
item.RunTimeTicks = GetRuntime(primaryTitle);
}
- return _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, null, titleNumber)
+ return _mediaEncoder.GetPrimaryPlaylistVobFiles(item.Path, titleNumber)
.Select(Path.GetFileName)
.ToArray();
}
diff --git a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs
index fc38d3832..c36c3af6a 100644
--- a/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs
+++ b/MediaBrowser.Providers/MediaInfo/VideoImageProvider.cs
@@ -9,6 +9,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
@@ -50,7 +51,7 @@ namespace MediaBrowser.Providers.MediaInfo
}
// No support for this
- if (video.VideoType == VideoType.Iso || video.VideoType == VideoType.Dvd || video.VideoType == VideoType.BluRay)
+ if (video.VideoType == VideoType.Dvd)
{
return Task.FromResult(new DynamicImageResponse { HasImage = false });
}
@@ -69,11 +70,7 @@ namespace MediaBrowser.Providers.MediaInfo
{
var protocol = item.PathProtocol ?? MediaProtocol.File;
- var inputPath = MediaEncoderHelpers.GetInputArgument(
- _fileSystem,
- item.Path,
- null,
- item.GetPlayableStreamFileNames());
+ var inputPath = item.Path;
var mediaStreams =
item.GetMediaStreams();
@@ -107,7 +104,14 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
- extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, protocol, imageStream, videoIndex, cancellationToken).ConfigureAwait(false);
+ MediaSourceInfo mediaSource = new MediaSourceInfo
+ {
+ VideoType = item.VideoType,
+ IsoType = item.IsoType,
+ Protocol = item.PathProtocol.Value,
+ };
+
+ extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, mediaSource, imageStream, videoIndex, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -119,8 +123,14 @@ namespace MediaBrowser.Providers.MediaInfo
: TimeSpan.FromSeconds(10);
var videoStream = mediaStreams.FirstOrDefault(i => i.Type == MediaStreamType.Video);
+ var mediaSource = new MediaSourceInfo
+ {
+ VideoType = item.VideoType,
+ IsoType = item.IsoType,
+ Protocol = item.PathProtocol.Value,
+ };
- extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, protocol, videoStream, item.Video3DFormat, imageOffset, cancellationToken).ConfigureAwait(false);
+ extractedImagePath = await _mediaEncoder.ExtractVideoImage(inputPath, item.Container, mediaSource, videoStream, item.Video3DFormat, imageOffset, cancellationToken).ConfigureAwait(false);
}
return new DynamicImageResponse
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs
index e3a1decb8..293087da7 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AlbumImageProvider.cs
@@ -103,6 +103,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public bool Supports(BaseItem item)
- => Plugin.Instance.Configuration.Enable && item is MusicAlbum;
+ => item is MusicAlbum;
}
}
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs
index e6d89e688..97bba10ba 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/AlbumProvider.cs
@@ -56,13 +56,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<MusicAlbum>();
-
- // TODO maybe remove when artist metadata can be disabled
- if (!Plugin.Instance.Configuration.Enable)
- {
- return result;
- }
-
var id = info.GetReleaseGroupId();
if (!string.IsNullOrWhiteSpace(id))
@@ -175,7 +168,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
Directory.CreateDirectory(Path.GetDirectoryName(path));
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var xmlFileStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read, IODefaults.FileStreamBufferSize, true);
await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false);
}
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs
index 54851c4d0..d250acfa8 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/ArtistImageProvider.cs
@@ -144,6 +144,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public bool Supports(BaseItem item)
- => Plugin.Instance.Configuration.Enable && item is MusicArtist;
+ => item is MusicArtist;
}
}
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs
index 72dad8a25..a2a03e1f9 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/ArtistProvider.cs
@@ -57,13 +57,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<MusicArtist>();
-
- // TODO maybe remove when artist metadata can be disabled
- if (!Plugin.Instance.Configuration.Enable)
- {
- return result;
- }
-
var id = info.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(id))
@@ -156,7 +149,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
Directory.CreateDirectory(Path.GetDirectoryName(path));
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs
index 9657a290f..664474dcd 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs
+++ b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/PluginConfiguration.cs
@@ -6,8 +6,6 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class PluginConfiguration : BasePluginConfiguration
{
- public bool Enable { get; set; }
-
public bool ReplaceAlbumName { get; set; }
}
}
diff --git a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html
index 82f26a8f2..eab252005 100644
--- a/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html
+++ b/MediaBrowser.Providers/Plugins/AudioDb/Configuration/config.html
@@ -9,10 +9,6 @@
<div class="content-primary">
<form class="configForm">
<label class="checkboxContainer">
- <input is="emby-checkbox" type="checkbox" id="enable" />
- <span>Enable this provider for metadata searches on artists and albums.</span>
- </label>
- <label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="replaceAlbumName" />
<span>When an album is found during a metadata search, replace the name with the value on the server.</span>
</label>
@@ -32,9 +28,8 @@
.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
- document.querySelector('#enable').checked = config.Enable;
document.querySelector('#replaceAlbumName').checked = config.ReplaceAlbumName;
-
+
Dashboard.hideLoadingMsg();
});
});
@@ -42,14 +37,13 @@
document.querySelector('.configForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
-
+
ApiClient.getPluginConfiguration(PluginConfig.pluginId).then(function (config) {
- config.Enable = document.querySelector('#enable').checked;
config.ReplaceAlbumName = document.querySelector('#replaceAlbumName').checked;
-
+
ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
-
+
e.preventDefault();
return false;
});
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs
index f27da7ce6..ce9392402 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/ArtistProvider.cs
@@ -25,12 +25,6 @@ namespace MediaBrowser.Providers.Music
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
{
- // TODO maybe remove when artist metadata can be disabled
- if (!Plugin.Instance.Configuration.Enable)
- {
- return Enumerable.Empty<RemoteSearchResult>();
- }
-
var musicBrainzId = searchInfo.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(musicBrainzId))
@@ -38,7 +32,7 @@ namespace MediaBrowser.Providers.Music
var url = "/ws/2/artist/?query=arid:{0}" + musicBrainzId.ToString(CultureInfo.InvariantCulture);
using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return GetResultsFromResponse(stream);
}
else
@@ -49,7 +43,7 @@ namespace MediaBrowser.Providers.Music
var url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=\"{0}\"&dismax=true", UrlEncode(nameToSearch));
using (var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false))
- await using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
+ await using (var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
{
var results = GetResultsFromResponse(stream).ToList();
@@ -65,7 +59,7 @@ namespace MediaBrowser.Providers.Music
url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=artistaccent:\"{0}\"", UrlEncode(nameToSearch));
using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return GetResultsFromResponse(stream);
}
}
@@ -236,12 +230,6 @@ namespace MediaBrowser.Providers.Music
Item = new MusicArtist()
};
- // TODO maybe remove when artist metadata can be disabled
- if (!Plugin.Instance.Configuration.Enable)
- {
- return result;
- }
-
var musicBrainzId = id.GetMusicBrainzArtistId();
if (string.IsNullOrWhiteSpace(musicBrainzId))
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs
index 980da9a01..0cec9e359 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs
@@ -43,8 +43,6 @@ namespace MediaBrowser.Providers.Plugins.MusicBrainz
}
}
- public bool Enable { get; set; }
-
public bool ReplaceArtistName { get; set; }
}
}
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html
index 1945e6cb4..6f1296bb7 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/config.html
@@ -17,10 +17,6 @@
<div class="fieldDescription">Span of time between requests in milliseconds. The official server is limited to one request every two seconds.</div>
</div>
<label class="checkboxContainer">
- <input is="emby-checkbox" type="checkbox" id="enable" />
- <span>Enable this provider for metadata searches on artists and albums.</span>
- </label>
- <label class="checkboxContainer">
<input is="emby-checkbox" type="checkbox" id="replaceArtistName" />
<span>When an artist is found during a metadata search, replace the artist name with the value on the server.</span>
</label>
@@ -46,7 +42,7 @@
bubbles: true,
cancelable: false
}));
-
+
var rateLimit = document.querySelector('#rateLimit');
rateLimit.value = config.RateLimit;
rateLimit.dispatchEvent(new Event('change', {
@@ -54,26 +50,24 @@
cancelable: false
}));
- document.querySelector('#enable').checked = config.Enable;
document.querySelector('#replaceArtistName').checked = config.ReplaceArtistName;
Dashboard.hideLoadingMsg();
});
});
-
+
document.querySelector('.musicBrainzConfigForm')
.addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
-
+
ApiClient.getPluginConfiguration(MusicBrainzPluginConfig.uniquePluginId).then(function (config) {
config.Server = document.querySelector('#server').value;
config.RateLimit = document.querySelector('#rateLimit').value;
- config.Enable = document.querySelector('#enable').checked;
config.ReplaceArtistName = document.querySelector('#replaceArtistName').checked;
-
+
ApiClient.updatePluginConfiguration(MusicBrainzPluginConfig.uniquePluginId, config).then(Dashboard.processPluginConfigurationUpdateResult);
});
-
+
e.preventDefault();
return false;
});
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
index 31f0123dc..ef7933b1a 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
@@ -78,12 +78,6 @@ namespace MediaBrowser.Providers.Music
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken)
{
- // TODO maybe remove when artist metadata can be disabled
- if (!Plugin.Instance.Configuration.Enable)
- {
- return Enumerable.Empty<RemoteSearchResult>();
- }
-
var releaseId = searchInfo.GetReleaseId();
var releaseGroupId = searchInfo.GetReleaseGroupId();
@@ -125,7 +119,7 @@ namespace MediaBrowser.Providers.Music
if (!string.IsNullOrWhiteSpace(url))
{
using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return GetResultsFromResponse(stream);
}
@@ -194,12 +188,6 @@ namespace MediaBrowser.Providers.Music
Item = new MusicAlbum()
};
- // TODO maybe remove when artist metadata can be disabled
- if (!Plugin.Instance.Configuration.Enable)
- {
- return result;
- }
-
// If we have a release group Id but not a release Id...
if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId))
{
@@ -284,7 +272,7 @@ namespace MediaBrowser.Providers.Music
artistId);
using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var oReader = new StreamReader(stream, Encoding.UTF8);
var settings = new XmlReaderSettings
{
@@ -307,7 +295,7 @@ namespace MediaBrowser.Providers.Music
WebUtility.UrlEncode(artistName));
using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var oReader = new StreamReader(stream, Encoding.UTF8);
var settings = new XmlReaderSettings()
{
@@ -622,7 +610,7 @@ namespace MediaBrowser.Providers.Music
var url = "/ws/2/release?release-group=" + releaseGroupId.ToString(CultureInfo.InvariantCulture);
using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var oReader = new StreamReader(stream, Encoding.UTF8);
var settings = new XmlReaderSettings
{
@@ -649,7 +637,7 @@ namespace MediaBrowser.Providers.Music
var url = "/ws/2/release-group/?query=reid:" + releaseEntryId.ToString(CultureInfo.InvariantCulture);
using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var oReader = new StreamReader(stream, Encoding.UTF8);
var settings = new XmlReaderSettings
{
@@ -768,16 +756,7 @@ namespace MediaBrowser.Providers.Music
_stopWatchMusicBrainz.Restart();
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
-
- // MusicBrainz request a contact email address is supplied, as comment, in user agent field:
- // https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting#User-Agent .
- request.Headers.UserAgent.ParseAdd(string.Format(
- CultureInfo.InvariantCulture,
- "{0} ( {1} )",
- _appHost.ApplicationUserAgent,
- _appHost.ApplicationUserAgentAddress));
-
- response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(request).ConfigureAwait(false);
+ response = await _httpClientFactory.CreateClient(NamedClient.MusicBrainz).SendAsync(request).ConfigureAwait(false);
// We retry a finite number of times, and only whilst MB is indicating 503 (throttling).
}
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
index 705359d2c..43d8af75f 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs
@@ -133,7 +133,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var url = OmdbProvider.GetOmdbUrl(urlQuery);
using var response = await OmdbProvider.GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var resultList = new List<SearchResult>();
if (isSearch)
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index 9eed6172d..e540e4471 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
@@ -50,7 +50,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var result = await GetRootObject(imdbId, cancellationToken).ConfigureAwait(false);
- // Only take the name and rating if the user's language is set to english, since Omdb has no localization
+ // Only take the name and rating if the user's language is set to English, since Omdb has no localization
if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport)
{
item.Name = result.Title;
@@ -114,7 +114,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var seasonResult = await GetSeasonRootObject(seriesImdbId, seasonNumber, cancellationToken).ConfigureAwait(false);
- if (seasonResult == null)
+ if (seasonResult?.Episodes == null)
{
return false;
}
@@ -151,7 +151,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
return false;
}
- // Only take the name and rating if the user's language is set to english, since Omdb has no localization
+ // Only take the name and rating if the user's language is set to English, since Omdb has no localization
if (string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) || _configurationManager.Configuration.EnableNewOmdbSupport)
{
item.Name = result.Title;
@@ -298,7 +298,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
imdbParam));
using var response = await GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<RootObject>(stream).ConfigureAwait(false);
Directory.CreateDirectory(Path.GetDirectoryName(path));
_jsonSerializer.SerializeToFile(rootObject, path);
@@ -336,7 +336,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
seasonId));
using var response = await GetOmdbResponse(_httpClientFactory.CreateClient(NamedClient.Default), url, cancellationToken).ConfigureAwait(false);
- await using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var rootObject = await _jsonSerializer.DeserializeFromStreamAsync<SeasonRootObject>(stream).ConfigureAwait(false);
Directory.CreateDirectory(Path.GetDirectoryName(path));
_jsonSerializer.SerializeToFile(rootObject, path);
@@ -385,7 +385,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
var isConfiguredForEnglish = IsConfiguredForEnglish(item) || _configurationManager.Configuration.EnableNewOmdbSupport;
// Grab series genres because IMDb data is better than TVDB. Leave movies alone
- // But only do it if english is the preferred language because this data will not be localized
+ // But only do it if English is the preferred language because this data will not be localized
if (isConfiguredForEnglish && !string.IsNullOrWhiteSpace(result.Genre))
{
item.Genres = Array.Empty<string>();
@@ -401,7 +401,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
if (isConfiguredForEnglish)
{
- // Omdb is currently english only, so for other languages skip this and let secondary providers fill it in
+ // Omdb is currently English only, so for other languages skip this and let secondary providers fill it in
item.Overview = result.Plot;
}
@@ -455,7 +455,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
{
var lang = item.GetPreferredMetadataLanguage();
- // The data isn't localized and so can only be used for english users
+ // The data isn't localized and so can only be used for English users
return string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase);
}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs
deleted file mode 100644
index 690a52c4d..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/Configuration/PluginConfiguration.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-#pragma warning disable CS1591
-
-using MediaBrowser.Model.Plugins;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class PluginConfiguration : BasePluginConfiguration
- {
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs b/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs
deleted file mode 100644
index e7079ed3c..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/Plugin.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using MediaBrowser.Common.Configuration;
-using MediaBrowser.Common.Plugins;
-using MediaBrowser.Model.Serialization;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class Plugin : BasePlugin<PluginConfiguration>
- {
- public static Plugin Instance { get; private set; }
-
- public override Guid Id => new Guid("a677c0da-fac5-4cde-941a-7134223f14c8");
-
- public override string Name => "TheTVDB";
-
- public override string Description => "Get metadata for movies and other video content from TheTVDB.";
-
- // TODO remove when plugin removed from server.
- public override string ConfigurationFileName => "Jellyfin.Plugin.TheTvdb.xml";
-
- public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
- : base(applicationPaths, xmlSerializer)
- {
- Instance = this;
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs
deleted file mode 100644
index ce0dab701..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbClientManager.cs
+++ /dev/null
@@ -1,289 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Reflection;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using Microsoft.Extensions.Caching.Memory;
-using TvDbSharper;
-using TvDbSharper.Dto;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbClientManager
- {
- private const string DefaultLanguage = "en";
-
- private readonly IMemoryCache _cache;
- private readonly TvDbClient _tvDbClient;
- private DateTime _tokenCreatedAt;
-
- public TvdbClientManager(IMemoryCache memoryCache)
- {
- _cache = memoryCache;
- _tvDbClient = new TvDbClient();
- }
-
- private TvDbClient TvDbClient
- {
- get
- {
- if (string.IsNullOrEmpty(_tvDbClient.Authentication.Token))
- {
- _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult();
- _tokenCreatedAt = DateTime.Now;
- }
-
- // Refresh if necessary
- if (_tokenCreatedAt < DateTime.Now.Subtract(TimeSpan.FromHours(20)))
- {
- try
- {
- _tvDbClient.Authentication.RefreshTokenAsync().GetAwaiter().GetResult();
- }
- catch
- {
- _tvDbClient.Authentication.AuthenticateAsync(TvdbUtils.TvdbApiKey).GetAwaiter().GetResult();
- }
-
- _tokenCreatedAt = DateTime.Now;
- }
-
- return _tvDbClient;
- }
- }
-
- public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByNameAsync(string name, string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", name, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByNameAsync(name, cancellationToken));
- }
-
- public Task<TvDbResponse<Series>> GetSeriesByIdAsync(int tvdbId, string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", tvdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetAsync(tvdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<EpisodeRecord>> GetEpisodesAsync(int episodeTvdbId, string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("episode", episodeTvdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Episodes.GetAsync(episodeTvdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByImdbIdAsync(
- string imdbId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", imdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByImdbIdAsync(imdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<SeriesSearchResult[]>> GetSeriesByZap2ItIdAsync(
- string zap2ItId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("series", zap2ItId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Search.SearchSeriesByZap2ItIdAsync(zap2ItId, cancellationToken));
- }
-
- public Task<TvDbResponse<Actor[]>> GetActorsAsync(
- int tvdbId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("actors", tvdbId, language);
- return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetActorsAsync(tvdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<Image[]>> GetImagesAsync(
- int tvdbId,
- ImagesQuery imageQuery,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("images", tvdbId, language, imageQuery);
- return TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesAsync(tvdbId, imageQuery, cancellationToken));
- }
-
- public Task<TvDbResponse<Language[]>> GetLanguagesAsync(CancellationToken cancellationToken)
- {
- return TryGetValue("languages", null, () => TvDbClient.Languages.GetAllAsync(cancellationToken));
- }
-
- public Task<TvDbResponse<EpisodesSummary>> GetSeriesEpisodeSummaryAsync(
- int tvdbId,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey("seriesepisodesummary", tvdbId, language);
- return TryGetValue(cacheKey, language,
- () => TvDbClient.Series.GetEpisodesSummaryAsync(tvdbId, cancellationToken));
- }
-
- public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync(
- int tvdbId,
- int page,
- EpisodeQuery episodeQuery,
- string language,
- CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey(language, tvdbId, episodeQuery);
-
- return TryGetValue(cacheKey, language,
- () => TvDbClient.Series.GetEpisodesAsync(tvdbId, page, episodeQuery, cancellationToken));
- }
-
- public Task<string> GetEpisodeTvdbId(
- EpisodeInfo searchInfo,
- string language,
- CancellationToken cancellationToken)
- {
- searchInfo.SeriesProviderIds.TryGetValue(
- nameof(MetadataProvider.Tvdb),
- out var seriesTvdbId);
-
- var episodeQuery = new EpisodeQuery();
-
- // Prefer SxE over premiere date as it is more robust
- if (searchInfo.IndexNumber.HasValue && searchInfo.ParentIndexNumber.HasValue)
- {
- switch (searchInfo.SeriesDisplayOrder)
- {
- case "dvd":
- episodeQuery.DvdEpisode = searchInfo.IndexNumber.Value;
- episodeQuery.DvdSeason = searchInfo.ParentIndexNumber.Value;
- break;
- case "absolute":
- episodeQuery.AbsoluteNumber = searchInfo.IndexNumber.Value;
- break;
- default:
- // aired order
- episodeQuery.AiredEpisode = searchInfo.IndexNumber.Value;
- episodeQuery.AiredSeason = searchInfo.ParentIndexNumber.Value;
- break;
- }
- }
- else if (searchInfo.PremiereDate.HasValue)
- {
- // tvdb expects yyyy-mm-dd format
- episodeQuery.FirstAired = searchInfo.PremiereDate.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
- }
-
- return GetEpisodeTvdbId(Convert.ToInt32(seriesTvdbId, CultureInfo.InvariantCulture), episodeQuery, language, cancellationToken);
- }
-
- public async Task<string> GetEpisodeTvdbId(
- int seriesTvdbId,
- EpisodeQuery episodeQuery,
- string language,
- CancellationToken cancellationToken)
- {
- var episodePage =
- await GetEpisodesPageAsync(Convert.ToInt32(seriesTvdbId), episodeQuery, language, cancellationToken)
- .ConfigureAwait(false);
- return episodePage.Data.FirstOrDefault()?.Id.ToString(CultureInfo.InvariantCulture);
- }
-
- public Task<TvDbResponse<EpisodeRecord[]>> GetEpisodesPageAsync(
- int tvdbId,
- EpisodeQuery episodeQuery,
- string language,
- CancellationToken cancellationToken)
- {
- return GetEpisodesPageAsync(tvdbId, 1, episodeQuery, language, cancellationToken);
- }
-
- public async IAsyncEnumerable<KeyType> GetImageKeyTypesForSeriesAsync(int tvdbId, string language, [EnumeratorCancellation] CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey(nameof(TvDbClient.Series.GetImagesSummaryAsync), tvdbId);
- var imagesSummary = await TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesSummaryAsync(tvdbId, cancellationToken)).ConfigureAwait(false);
-
- if (imagesSummary.Data.Fanart > 0)
- {
- yield return KeyType.Fanart;
- }
-
- if (imagesSummary.Data.Series > 0)
- {
- yield return KeyType.Series;
- }
-
- if (imagesSummary.Data.Poster > 0)
- {
- yield return KeyType.Poster;
- }
- }
-
- public async IAsyncEnumerable<KeyType> GetImageKeyTypesForSeasonAsync(int tvdbId, string language, [EnumeratorCancellation] CancellationToken cancellationToken)
- {
- var cacheKey = GenerateKey(nameof(TvDbClient.Series.GetImagesSummaryAsync), tvdbId);
- var imagesSummary = await TryGetValue(cacheKey, language, () => TvDbClient.Series.GetImagesSummaryAsync(tvdbId, cancellationToken)).ConfigureAwait(false);
-
- if (imagesSummary.Data.Season > 0)
- {
- yield return KeyType.Season;
- }
-
- if (imagesSummary.Data.Fanart > 0)
- {
- yield return KeyType.Fanart;
- }
-
- // TODO seasonwide is not supported in TvDbSharper
- }
-
- private async Task<T> TryGetValue<T>(string key, string language, Func<Task<T>> resultFactory)
- {
- if (_cache.TryGetValue(key, out T cachedValue))
- {
- return cachedValue;
- }
-
- _tvDbClient.AcceptedLanguage = TvdbUtils.NormalizeLanguage(language) ?? DefaultLanguage;
- var result = await resultFactory.Invoke().ConfigureAwait(false);
- _cache.Set(key, result, TimeSpan.FromHours(1));
- return result;
- }
-
- private static string GenerateKey(params object[] objects)
- {
- var key = string.Empty;
-
- foreach (var obj in objects)
- {
- var objType = obj.GetType();
- if (objType.IsPrimitive || objType == typeof(string))
- {
- key += obj + ";";
- }
- else
- {
- foreach (PropertyInfo propertyInfo in objType.GetProperties())
- {
- var currentValue = propertyInfo.GetValue(obj, null);
- if (currentValue == null)
- {
- continue;
- }
-
- key += propertyInfo.Name + "=" + currentValue + ";";
- }
- }
- }
-
- return key;
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs
deleted file mode 100644
index 50a876d6c..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeImageProvider.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Net.Http;
-using System.Globalization;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbEpisodeImageProvider : IRemoteImageProvider
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbEpisodeImageProvider> _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbEpisodeImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbEpisodeImageProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public string Name => "TheTVDB";
-
- public bool Supports(BaseItem item)
- {
- return item is Episode;
- }
-
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new List<ImageType>
- {
- ImageType.Primary
- };
- }
-
- public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- var episode = (Episode)item;
- var series = episode.Series;
- var imageResult = new List<RemoteImageInfo>();
- var language = item.GetPreferredMetadataLanguage();
- if (series != null && TvdbSeriesProvider.IsValidSeries(series.ProviderIds))
- {
- // Process images
- try
- {
- string episodeTvdbId = null;
-
- if (episode.IndexNumber.HasValue && episode.ParentIndexNumber.HasValue)
- {
- var episodeInfo = new EpisodeInfo
- {
- IndexNumber = episode.IndexNumber.Value,
- ParentIndexNumber = episode.ParentIndexNumber.Value,
- SeriesProviderIds = series.ProviderIds,
- SeriesDisplayOrder = series.DisplayOrder
- };
-
- episodeTvdbId = await _tvdbClientManager
- .GetEpisodeTvdbId(episodeInfo, language, cancellationToken).ConfigureAwait(false);
- }
-
- if (string.IsNullOrEmpty(episodeTvdbId))
- {
- _logger.LogError(
- "Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}",
- episode.ParentIndexNumber,
- episode.IndexNumber,
- series.GetProviderId(MetadataProvider.Tvdb));
- return imageResult;
- }
-
- var episodeResult =
- await _tvdbClientManager
- .GetEpisodesAsync(Convert.ToInt32(episodeTvdbId, CultureInfo.InvariantCulture), language, cancellationToken)
- .ConfigureAwait(false);
-
- var image = GetImageInfo(episodeResult.Data);
- if (image != null)
- {
- imageResult.Add(image);
- }
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve episode images for series {TvDbId}", series.GetProviderId(MetadataProvider.Tvdb));
- }
- }
-
- return imageResult;
- }
-
- private RemoteImageInfo GetImageInfo(EpisodeRecord episode)
- {
- if (string.IsNullOrEmpty(episode.Filename))
- {
- return null;
- }
-
- return new RemoteImageInfo
- {
- Width = Convert.ToInt32(episode.ThumbWidth, CultureInfo.InvariantCulture),
- Height = Convert.ToInt32(episode.ThumbHeight, CultureInfo.InvariantCulture),
- ProviderName = Name,
- Url = TvdbUtils.BannerUrl + episode.Filename,
- Type = ImageType.Primary
- };
- }
-
- public int Order => 0;
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs
deleted file mode 100644
index fd72ea4a8..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbEpisodeProvider.cs
+++ /dev/null
@@ -1,262 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- /// <summary>
- /// Class RemoteEpisodeProvider.
- /// </summary>
- public class TvdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbEpisodeProvider> _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbEpisodeProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbEpisodeProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken)
- {
- var list = new List<RemoteSearchResult>();
-
- // Either an episode number or date must be provided; and the dictionary of provider ids must be valid
- if ((searchInfo.IndexNumber == null && searchInfo.PremiereDate == null)
- || !TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds))
- {
- return list;
- }
-
- var metadataResult = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false);
-
- if (!metadataResult.HasMetadata)
- {
- return list;
- }
-
- var item = metadataResult.Item;
-
- list.Add(new RemoteSearchResult
- {
- IndexNumber = item.IndexNumber,
- Name = item.Name,
- ParentIndexNumber = item.ParentIndexNumber,
- PremiereDate = item.PremiereDate,
- ProductionYear = item.ProductionYear,
- ProviderIds = item.ProviderIds,
- SearchProviderName = Name,
- IndexNumberEnd = item.IndexNumberEnd
- });
-
- return list;
- }
-
- public string Name => "TheTVDB";
-
- public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo searchInfo, CancellationToken cancellationToken)
- {
- var result = new MetadataResult<Episode>
- {
- QueriedById = true
- };
-
- if (TvdbSeriesProvider.IsValidSeries(searchInfo.SeriesProviderIds) &&
- (searchInfo.IndexNumber.HasValue || searchInfo.PremiereDate.HasValue))
- {
- result = await GetEpisode(searchInfo, cancellationToken).ConfigureAwait(false);
- }
- else
- {
- _logger.LogDebug("No series identity found for {EpisodeName}", searchInfo.Name);
- }
-
- return result;
- }
-
- private async Task<MetadataResult<Episode>> GetEpisode(EpisodeInfo searchInfo, CancellationToken cancellationToken)
- {
- var result = new MetadataResult<Episode>
- {
- QueriedById = true
- };
-
- string seriesTvdbId = searchInfo.GetProviderId(MetadataProvider.Tvdb);
- string episodeTvdbId = null;
- try
- {
- episodeTvdbId = await _tvdbClientManager
- .GetEpisodeTvdbId(searchInfo, searchInfo.MetadataLanguage, cancellationToken)
- .ConfigureAwait(false);
- if (string.IsNullOrEmpty(episodeTvdbId))
- {
- _logger.LogError(
- "Episode {SeasonNumber}x{EpisodeNumber} not found for series {SeriesTvdbId}",
- searchInfo.ParentIndexNumber, searchInfo.IndexNumber, seriesTvdbId);
- return result;
- }
-
- var episodeResult = await _tvdbClientManager.GetEpisodesAsync(
- Convert.ToInt32(episodeTvdbId), searchInfo.MetadataLanguage,
- cancellationToken).ConfigureAwait(false);
-
- result = MapEpisodeToResult(searchInfo, episodeResult.Data);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve episode with id {EpisodeTvDbId}, series id {SeriesTvdbId}", episodeTvdbId, seriesTvdbId);
- }
-
- return result;
- }
-
- private static MetadataResult<Episode> MapEpisodeToResult(EpisodeInfo id, EpisodeRecord episode)
- {
- var result = new MetadataResult<Episode>
- {
- HasMetadata = true,
- Item = new Episode
- {
- IndexNumber = id.IndexNumber,
- ParentIndexNumber = id.ParentIndexNumber,
- IndexNumberEnd = id.IndexNumberEnd,
- AirsBeforeEpisodeNumber = episode.AirsBeforeEpisode,
- AirsAfterSeasonNumber = episode.AirsAfterSeason,
- AirsBeforeSeasonNumber = episode.AirsBeforeSeason,
- Name = episode.EpisodeName,
- Overview = episode.Overview,
- CommunityRating = (float?)episode.SiteRating,
- OfficialRating = episode.ContentRating,
- }
- };
- result.ResetPeople();
-
- var item = result.Item;
- item.SetProviderId(MetadataProvider.Tvdb, episode.Id.ToString());
- item.SetProviderId(MetadataProvider.Imdb, episode.ImdbId);
-
- if (string.Equals(id.SeriesDisplayOrder, "dvd", StringComparison.OrdinalIgnoreCase))
- {
- item.IndexNumber = Convert.ToInt32(episode.DvdEpisodeNumber ?? episode.AiredEpisodeNumber);
- item.ParentIndexNumber = episode.DvdSeason ?? episode.AiredSeason;
- }
- else if (string.Equals(id.SeriesDisplayOrder, "absolute", StringComparison.OrdinalIgnoreCase))
- {
- if (episode.AbsoluteNumber.GetValueOrDefault() != 0)
- {
- item.IndexNumber = episode.AbsoluteNumber;
- }
- }
- else if (episode.AiredEpisodeNumber.HasValue)
- {
- item.IndexNumber = episode.AiredEpisodeNumber;
- }
- else if (episode.AiredSeason.HasValue)
- {
- item.ParentIndexNumber = episode.AiredSeason;
- }
-
- if (DateTime.TryParse(episode.FirstAired, out var date))
- {
- // dates from tvdb are UTC but without offset or Z
- item.PremiereDate = date;
- item.ProductionYear = date.Year;
- }
-
- foreach (var director in episode.Directors)
- {
- result.AddPerson(new PersonInfo
- {
- Name = director,
- Type = PersonType.Director
- });
- }
-
- // GuestStars is a weird list of names and roles
- // Example:
- // 1: Some Actor (Role1
- // 2: Role2
- // 3: Role3)
- // 4: Another Actor (Role1
- // ...
- for (var i = 0; i < episode.GuestStars.Length; ++i)
- {
- var currentActor = episode.GuestStars[i];
- var roleStartIndex = currentActor.IndexOf('(', StringComparison.Ordinal);
-
- if (roleStartIndex == -1)
- {
- result.AddPerson(new PersonInfo
- {
- Type = PersonType.GuestStar,
- Name = currentActor,
- Role = string.Empty
- });
- continue;
- }
-
- var roles = new List<string> { currentActor.Substring(roleStartIndex + 1) };
-
- // Fetch all roles
- for (var j = i + 1; j < episode.GuestStars.Length; ++j)
- {
- var currentRole = episode.GuestStars[j];
- var roleEndIndex = currentRole.IndexOf(')', StringComparison.Ordinal);
-
- if (roleEndIndex == -1)
- {
- roles.Add(currentRole);
- continue;
- }
-
- roles.Add(currentRole.TrimEnd(')'));
- // Update the outer index (keep in mind it adds 1 after the iteration)
- i = j;
- break;
- }
-
- result.AddPerson(new PersonInfo
- {
- Type = PersonType.GuestStar,
- Name = currentActor.Substring(0, roleStartIndex).Trim(),
- Role = string.Join(", ", roles)
- });
- }
-
- foreach (var writer in episode.Writers)
- {
- result.AddPerson(new PersonInfo
- {
- Name = writer,
- Type = PersonType.Writer
- });
- }
-
- result.ResultLanguage = episode.Language.EpisodeName;
- return result;
- }
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
-
- public int Order => 0;
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs
deleted file mode 100644
index a5cd425f6..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbPersonImageProvider.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Dto;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbPersonImageProvider : IRemoteImageProvider, IHasOrder
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbPersonImageProvider> _logger;
- private readonly ILibraryManager _libraryManager;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbPersonImageProvider(ILibraryManager libraryManager, IHttpClientFactory httpClientFactory, ILogger<TvdbPersonImageProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _libraryManager = libraryManager;
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- /// <inheritdoc />
- public string Name => "TheTVDB";
-
- /// <inheritdoc />
- public int Order => 1;
-
- /// <inheritdoc />
- public bool Supports(BaseItem item) => item is Person;
-
- /// <inheritdoc />
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- yield return ImageType.Primary;
- }
-
- /// <inheritdoc />
- public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- var seriesWithPerson = _libraryManager.GetItemList(new InternalItemsQuery
- {
- IncludeItemTypes = new[] { nameof(Series) },
- PersonIds = new[] { item.Id },
- DtoOptions = new DtoOptions(false)
- {
- EnableImages = false
- }
- }).Cast<Series>()
- .Where(i => TvdbSeriesProvider.IsValidSeries(i.ProviderIds))
- .ToList();
-
- var infos = (await Task.WhenAll(seriesWithPerson.Select(async i =>
- await GetImageFromSeriesData(i, item.Name, cancellationToken).ConfigureAwait(false)))
- .ConfigureAwait(false))
- .Where(i => i != null)
- .Take(1);
-
- return infos;
- }
-
- private async Task<RemoteImageInfo> GetImageFromSeriesData(Series series, string personName, CancellationToken cancellationToken)
- {
- var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProvider.Tvdb));
-
- try
- {
- var actorsResult = await _tvdbClientManager
- .GetActorsAsync(tvdbId, series.GetPreferredMetadataLanguage(), cancellationToken)
- .ConfigureAwait(false);
- var actor = actorsResult.Data.FirstOrDefault(a =>
- string.Equals(a.Name, personName, StringComparison.OrdinalIgnoreCase) &&
- !string.IsNullOrEmpty(a.Image));
- if (actor == null)
- {
- return null;
- }
-
- return new RemoteImageInfo
- {
- Url = TvdbUtils.BannerUrl + actor.Image,
- Type = ImageType.Primary,
- ProviderName = Name
- };
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve actor {ActorName} from series {SeriesTvdbId}", personName, tvdbId);
- return null;
- }
- }
-
- /// <inheritdoc />
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs
deleted file mode 100644
index 49576d488..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeasonImageProvider.cs
+++ /dev/null
@@ -1,155 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-using RatingType = MediaBrowser.Model.Dto.RatingType;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbSeasonImageProvider : IRemoteImageProvider, IHasOrder
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbSeasonImageProvider> _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbSeasonImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeasonImageProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public string Name => ProviderName;
-
- public static string ProviderName => "TheTVDB";
-
- public bool Supports(BaseItem item)
- {
- return item is Season;
- }
-
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new List<ImageType>
- {
- ImageType.Primary,
- ImageType.Banner,
- ImageType.Backdrop
- };
- }
-
- public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- var season = (Season)item;
- var series = season.Series;
-
- if (series == null || !season.IndexNumber.HasValue || !TvdbSeriesProvider.IsValidSeries(series.ProviderIds))
- {
- return Array.Empty<RemoteImageInfo>();
- }
-
- var tvdbId = Convert.ToInt32(series.GetProviderId(MetadataProvider.Tvdb));
- var seasonNumber = season.IndexNumber.Value;
- var language = item.GetPreferredMetadataLanguage();
- var remoteImages = new List<RemoteImageInfo>();
-
- var keyTypes = _tvdbClientManager.GetImageKeyTypesForSeasonAsync(tvdbId, language, cancellationToken).ConfigureAwait(false);
- await foreach (var keyType in keyTypes)
- {
- var imageQuery = new ImagesQuery
- {
- KeyType = keyType,
- SubKey = seasonNumber.ToString()
- };
- try
- {
- var imageResults = await _tvdbClientManager
- .GetImagesAsync(tvdbId, imageQuery, language, cancellationToken).ConfigureAwait(false);
- remoteImages.AddRange(GetImages(imageResults.Data, language));
- }
- catch (TvDbServerException)
- {
- _logger.LogDebug("No images of type {KeyType} found for series {TvdbId}", keyType, tvdbId);
- }
- }
-
- return remoteImages;
- }
-
- private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage)
- {
- var list = new List<RemoteImageInfo>();
- // any languages with null ids are ignored
- var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data.Where(x => x.Id.HasValue);
- foreach (Image image in images)
- {
- var imageInfo = new RemoteImageInfo
- {
- RatingType = RatingType.Score,
- CommunityRating = (double?)image.RatingsInfo.Average,
- VoteCount = image.RatingsInfo.Count,
- Url = TvdbUtils.BannerUrl + image.FileName,
- ProviderName = ProviderName,
- Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation,
- ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail
- };
-
- var resolution = image.Resolution.Split('x');
- if (resolution.Length == 2)
- {
- imageInfo.Width = Convert.ToInt32(resolution[0]);
- imageInfo.Height = Convert.ToInt32(resolution[1]);
- }
-
- imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType);
- list.Add(imageInfo);
- }
-
- var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase);
- return list.OrderByDescending(i =>
- {
- if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase))
- {
- return 3;
- }
-
- if (!isLanguageEn)
- {
- if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase))
- {
- return 2;
- }
- }
-
- if (string.IsNullOrEmpty(i.Language))
- {
- return isLanguageEn ? 3 : 2;
- }
-
- return 0;
- })
- .ThenByDescending(i => i.CommunityRating ?? 0)
- .ThenByDescending(i => i.VoteCount ?? 0);
- }
-
- public int Order => 0;
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs
deleted file mode 100644
index d96840e51..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesImageProvider.cs
+++ /dev/null
@@ -1,153 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-using RatingType = MediaBrowser.Model.Dto.RatingType;
-using Series = MediaBrowser.Controller.Entities.TV.Series;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbSeriesImageProvider : IRemoteImageProvider, IHasOrder
- {
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbSeriesImageProvider> _logger;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbSeriesImageProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeriesImageProvider> logger, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public string Name => ProviderName;
-
- public static string ProviderName => "TheTVDB";
-
- public bool Supports(BaseItem item)
- {
- return item is Series;
- }
-
- public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
- {
- return new List<ImageType>
- {
- ImageType.Primary,
- ImageType.Banner,
- ImageType.Backdrop
- };
- }
-
- public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
- {
- if (!TvdbSeriesProvider.IsValidSeries(item.ProviderIds))
- {
- return Array.Empty<RemoteImageInfo>();
- }
-
- var language = item.GetPreferredMetadataLanguage();
- var remoteImages = new List<RemoteImageInfo>();
- var tvdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tvdb));
- var allowedKeyTypes = _tvdbClientManager.GetImageKeyTypesForSeriesAsync(tvdbId, language, cancellationToken)
- .ConfigureAwait(false);
- await foreach (KeyType keyType in allowedKeyTypes)
- {
- var imageQuery = new ImagesQuery
- {
- KeyType = keyType
- };
- try
- {
- var imageResults =
- await _tvdbClientManager.GetImagesAsync(tvdbId, imageQuery, language, cancellationToken)
- .ConfigureAwait(false);
-
- remoteImages.AddRange(GetImages(imageResults.Data, language));
- }
- catch (TvDbServerException)
- {
- _logger.LogDebug("No images of type {KeyType} exist for series {TvDbId}", keyType,
- tvdbId);
- }
- }
-
- return remoteImages;
- }
-
- private IEnumerable<RemoteImageInfo> GetImages(Image[] images, string preferredLanguage)
- {
- var list = new List<RemoteImageInfo>();
- var languages = _tvdbClientManager.GetLanguagesAsync(CancellationToken.None).Result.Data;
-
- foreach (Image image in images)
- {
- var imageInfo = new RemoteImageInfo
- {
- RatingType = RatingType.Score,
- CommunityRating = (double?)image.RatingsInfo.Average,
- VoteCount = image.RatingsInfo.Count,
- Url = TvdbUtils.BannerUrl + image.FileName,
- ProviderName = Name,
- Language = languages.FirstOrDefault(lang => lang.Id == image.LanguageId)?.Abbreviation,
- ThumbnailUrl = TvdbUtils.BannerUrl + image.Thumbnail
- };
-
- var resolution = image.Resolution.Split('x');
- if (resolution.Length == 2)
- {
- imageInfo.Width = Convert.ToInt32(resolution[0]);
- imageInfo.Height = Convert.ToInt32(resolution[1]);
- }
-
- imageInfo.Type = TvdbUtils.GetImageTypeFromKeyType(image.KeyType);
- list.Add(imageInfo);
- }
-
- var isLanguageEn = string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase);
- return list.OrderByDescending(i =>
- {
- if (string.Equals(preferredLanguage, i.Language, StringComparison.OrdinalIgnoreCase))
- {
- return 3;
- }
-
- if (!isLanguageEn)
- {
- if (string.Equals("en", i.Language, StringComparison.OrdinalIgnoreCase))
- {
- return 2;
- }
- }
-
- if (string.IsNullOrEmpty(i.Language))
- {
- return isLanguageEn ? 3 : 2;
- }
-
- return 0;
- })
- .ThenByDescending(i => i.CommunityRating ?? 0)
- .ThenByDescending(i => i.VoteCount ?? 0);
- }
-
- public int Order => 0;
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs
deleted file mode 100644
index e5a3e9a6a..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbSeriesProvider.cs
+++ /dev/null
@@ -1,419 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Net.Http;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using MediaBrowser.Common.Net;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.Library;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Globalization;
-using MediaBrowser.Model.Providers;
-using Microsoft.Extensions.Logging;
-using TvDbSharper;
-using TvDbSharper.Dto;
-using Series = MediaBrowser.Controller.Entities.TV.Series;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public class TvdbSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder
- {
- internal static TvdbSeriesProvider Current { get; private set; }
-
- private readonly IHttpClientFactory _httpClientFactory;
- private readonly ILogger<TvdbSeriesProvider> _logger;
- private readonly ILibraryManager _libraryManager;
- private readonly ILocalizationManager _localizationManager;
- private readonly TvdbClientManager _tvdbClientManager;
-
- public TvdbSeriesProvider(IHttpClientFactory httpClientFactory, ILogger<TvdbSeriesProvider> logger, ILibraryManager libraryManager, ILocalizationManager localizationManager, TvdbClientManager tvdbClientManager)
- {
- _httpClientFactory = httpClientFactory;
- _logger = logger;
- _libraryManager = libraryManager;
- _localizationManager = localizationManager;
- Current = this;
- _tvdbClientManager = tvdbClientManager;
- }
-
- public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken)
- {
- if (IsValidSeries(searchInfo.ProviderIds))
- {
- var metadata = await GetMetadata(searchInfo, cancellationToken).ConfigureAwait(false);
-
- if (metadata.HasMetadata)
- {
- return new List<RemoteSearchResult>
- {
- new RemoteSearchResult
- {
- Name = metadata.Item.Name,
- PremiereDate = metadata.Item.PremiereDate,
- ProductionYear = metadata.Item.ProductionYear,
- ProviderIds = metadata.Item.ProviderIds,
- SearchProviderName = Name
- }
- };
- }
- }
-
- return await FindSeries(searchInfo.Name, searchInfo.Year, searchInfo.MetadataLanguage, cancellationToken).ConfigureAwait(false);
- }
-
- public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo itemId, CancellationToken cancellationToken)
- {
- var result = new MetadataResult<Series>
- {
- QueriedById = true
- };
-
- if (!IsValidSeries(itemId.ProviderIds))
- {
- result.QueriedById = false;
- await Identify(itemId).ConfigureAwait(false);
- }
-
- cancellationToken.ThrowIfCancellationRequested();
-
- if (IsValidSeries(itemId.ProviderIds))
- {
- result.Item = new Series();
- result.HasMetadata = true;
-
- await FetchSeriesData(result, itemId.MetadataLanguage, itemId.ProviderIds, cancellationToken)
- .ConfigureAwait(false);
- }
-
- return result;
- }
-
- private async Task FetchSeriesData(MetadataResult<Series> result, string metadataLanguage, Dictionary<string, string> seriesProviderIds, CancellationToken cancellationToken)
- {
- var series = result.Item;
-
- if (seriesProviderIds.TryGetValue(MetadataProvider.Tvdb.ToString(), out var tvdbId) && !string.IsNullOrEmpty(tvdbId))
- {
- series.SetProviderId(MetadataProvider.Tvdb, tvdbId);
- }
-
- if (seriesProviderIds.TryGetValue(MetadataProvider.Imdb.ToString(), out var imdbId) && !string.IsNullOrEmpty(imdbId))
- {
- series.SetProviderId(MetadataProvider.Imdb, imdbId);
- tvdbId = await GetSeriesByRemoteId(imdbId, MetadataProvider.Imdb.ToString(), metadataLanguage,
- cancellationToken).ConfigureAwait(false);
- }
-
- if (seriesProviderIds.TryGetValue(MetadataProvider.Zap2It.ToString(), out var zap2It) && !string.IsNullOrEmpty(zap2It))
- {
- series.SetProviderId(MetadataProvider.Zap2It, zap2It);
- tvdbId = await GetSeriesByRemoteId(zap2It, MetadataProvider.Zap2It.ToString(), metadataLanguage,
- cancellationToken).ConfigureAwait(false);
- }
-
- try
- {
- var seriesResult =
- await _tvdbClientManager
- .GetSeriesByIdAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken)
- .ConfigureAwait(false);
- await MapSeriesToResult(result, seriesResult.Data, metadataLanguage).ConfigureAwait(false);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve series with id {TvdbId}", tvdbId);
- return;
- }
-
- cancellationToken.ThrowIfCancellationRequested();
-
- result.ResetPeople();
-
- try
- {
- var actorsResult = await _tvdbClientManager
- .GetActorsAsync(Convert.ToInt32(tvdbId), metadataLanguage, cancellationToken).ConfigureAwait(false);
- MapActorsToResult(result, actorsResult.Data);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve actors for series {TvdbId}", tvdbId);
- }
- }
-
- private async Task<string> GetSeriesByRemoteId(string id, string idType, string language, CancellationToken cancellationToken)
- {
- TvDbResponse<SeriesSearchResult[]> result = null;
-
- try
- {
- if (string.Equals(idType, MetadataProvider.Zap2It.ToString(), StringComparison.OrdinalIgnoreCase))
- {
- result = await _tvdbClientManager.GetSeriesByZap2ItIdAsync(id, language, cancellationToken)
- .ConfigureAwait(false);
- }
- else
- {
- result = await _tvdbClientManager.GetSeriesByImdbIdAsync(id, language, cancellationToken)
- .ConfigureAwait(false);
- }
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to retrieve series with remote id {RemoteId}", id);
- }
-
- return result?.Data[0].Id.ToString(CultureInfo.InvariantCulture);
- }
-
- /// <summary>
- /// Check whether a dictionary of provider IDs includes an entry for a valid TV metadata provider.
- /// </summary>
- /// <param name="seriesProviderIds">The dictionary to check.</param>
- /// <returns>True, if the dictionary contains a valid TV provider ID, otherwise false.</returns>
- internal static bool IsValidSeries(Dictionary<string, string> seriesProviderIds)
- {
- return seriesProviderIds.ContainsKey(MetadataProvider.Tvdb.ToString()) ||
- seriesProviderIds.ContainsKey(MetadataProvider.Imdb.ToString()) ||
- seriesProviderIds.ContainsKey(MetadataProvider.Zap2It.ToString());
- }
-
- /// <summary>
- /// Finds the series.
- /// </summary>
- /// <param name="name">The name.</param>
- /// <param name="year">The year.</param>
- /// <param name="language">The language.</param>
- /// <param name="cancellationToken">The cancellation token.</param>
- /// <returns>Task{System.String}.</returns>
- private async Task<IEnumerable<RemoteSearchResult>> FindSeries(string name, int? year, string language, CancellationToken cancellationToken)
- {
- var results = await FindSeriesInternal(name, language, cancellationToken).ConfigureAwait(false);
-
- if (results.Count == 0)
- {
- var parsedName = _libraryManager.ParseName(name);
- var nameWithoutYear = parsedName.Name;
-
- if (!string.IsNullOrWhiteSpace(nameWithoutYear) && !string.Equals(nameWithoutYear, name, StringComparison.OrdinalIgnoreCase))
- {
- results = await FindSeriesInternal(nameWithoutYear, language, cancellationToken).ConfigureAwait(false);
- }
- }
-
- return results.Where(i =>
- {
- if (year.HasValue && i.ProductionYear.HasValue)
- {
- // Allow one year tolerance
- return Math.Abs(year.Value - i.ProductionYear.Value) <= 1;
- }
-
- return true;
- });
- }
-
- private async Task<List<RemoteSearchResult>> FindSeriesInternal(string name, string language, CancellationToken cancellationToken)
- {
- var comparableName = GetComparableName(name);
- var list = new List<Tuple<List<string>, RemoteSearchResult>>();
- TvDbResponse<SeriesSearchResult[]> result;
- try
- {
- result = await _tvdbClientManager.GetSeriesByNameAsync(comparableName, language, cancellationToken)
- .ConfigureAwait(false);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "No series results found for {Name}", comparableName);
- return new List<RemoteSearchResult>();
- }
-
- foreach (var seriesSearchResult in result.Data)
- {
- var tvdbTitles = new List<string>
- {
- GetComparableName(seriesSearchResult.SeriesName)
- };
- tvdbTitles.AddRange(seriesSearchResult.Aliases.Select(GetComparableName));
-
- DateTime.TryParse(seriesSearchResult.FirstAired, out var firstAired);
- var remoteSearchResult = new RemoteSearchResult
- {
- Name = tvdbTitles.FirstOrDefault(),
- ProductionYear = firstAired.Year,
- SearchProviderName = Name
- };
-
- if (!string.IsNullOrEmpty(seriesSearchResult.Banner))
- {
- // Results from their Search endpoints already include the /banners/ part in the url, because reasons...
- remoteSearchResult.ImageUrl = TvdbUtils.TvdbImageBaseUrl + seriesSearchResult.Banner;
- }
-
- try
- {
- var seriesSesult =
- await _tvdbClientManager.GetSeriesByIdAsync(seriesSearchResult.Id, language, cancellationToken)
- .ConfigureAwait(false);
- remoteSearchResult.SetProviderId(MetadataProvider.Imdb, seriesSesult.Data.ImdbId);
- remoteSearchResult.SetProviderId(MetadataProvider.Zap2It, seriesSesult.Data.Zap2itId);
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Unable to retrieve series with id {TvdbId}", seriesSearchResult.Id);
- }
-
- remoteSearchResult.SetProviderId(MetadataProvider.Tvdb, seriesSearchResult.Id.ToString());
- list.Add(new Tuple<List<string>, RemoteSearchResult>(tvdbTitles, remoteSearchResult));
- }
-
- return list
- .OrderBy(i => i.Item1.Contains(comparableName, StringComparer.OrdinalIgnoreCase) ? 0 : 1)
- .ThenBy(i => list.IndexOf(i))
- .Select(i => i.Item2)
- .ToList();
- }
-
- /// <summary>
- /// Gets the name of the comparable.
- /// </summary>
- /// <param name="name">The name.</param>
- /// <returns>System.String.</returns>
- private string GetComparableName(string name)
- {
- name = name.ToLowerInvariant();
- name = name.Normalize(NormalizationForm.FormKD);
- name = name.Replace(", the", string.Empty).Replace("the ", " ").Replace(" the ", " ");
- name = name.Replace("&", " and " );
- name = Regex.Replace(name, @"[\p{Lm}\p{Mn}]", string.Empty); // Remove diacritics, etc
- name = Regex.Replace(name, @"[\W\p{Pc}]+", " "); // Replace sequences of non-word characters and _ with " "
- return name.Trim();
- }
-
- private async Task MapSeriesToResult(MetadataResult<Series> result, TvDbSharper.Dto.Series tvdbSeries, string metadataLanguage)
- {
- Series series = result.Item;
- series.SetProviderId(MetadataProvider.Tvdb, tvdbSeries.Id.ToString());
- series.Name = tvdbSeries.SeriesName;
- series.Overview = (tvdbSeries.Overview ?? string.Empty).Trim();
- result.ResultLanguage = metadataLanguage;
- series.AirDays = TVUtils.GetAirDays(tvdbSeries.AirsDayOfWeek);
- series.AirTime = tvdbSeries.AirsTime;
- series.CommunityRating = (float?)tvdbSeries.SiteRating;
- series.SetProviderId(MetadataProvider.Imdb, tvdbSeries.ImdbId);
- series.SetProviderId(MetadataProvider.Zap2It, tvdbSeries.Zap2itId);
- if (Enum.TryParse(tvdbSeries.Status, true, out SeriesStatus seriesStatus))
- {
- series.Status = seriesStatus;
- }
-
- if (DateTime.TryParse(tvdbSeries.FirstAired, out var date))
- {
- // dates from tvdb are UTC but without offset or Z
- series.PremiereDate = date;
- series.ProductionYear = date.Year;
- }
-
- if (!string.IsNullOrEmpty(tvdbSeries.Runtime) && double.TryParse(tvdbSeries.Runtime, out double runtime))
- {
- series.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks;
- }
-
- foreach (var genre in tvdbSeries.Genre)
- {
- series.AddGenre(genre);
- }
-
- if (!string.IsNullOrEmpty(tvdbSeries.Network))
- {
- series.AddStudio(tvdbSeries.Network);
- }
-
- if (result.Item.Status.HasValue && result.Item.Status.Value == SeriesStatus.Ended)
- {
- try
- {
- var episodeSummary = await _tvdbClientManager.GetSeriesEpisodeSummaryAsync(tvdbSeries.Id, metadataLanguage, CancellationToken.None).ConfigureAwait(false);
-
- if (episodeSummary.Data.AiredSeasons.Length != 0)
- {
- var maxSeasonNumber = episodeSummary.Data.AiredSeasons.Max(s => Convert.ToInt32(s, CultureInfo.InvariantCulture));
- var episodeQuery = new EpisodeQuery
- {
- AiredSeason = maxSeasonNumber
- };
- var episodesPage = await _tvdbClientManager.GetEpisodesPageAsync(tvdbSeries.Id, episodeQuery, metadataLanguage, CancellationToken.None).ConfigureAwait(false);
-
- result.Item.EndDate = episodesPage.Data
- .Select(e => DateTime.TryParse(e.FirstAired, out var firstAired) ? firstAired : (DateTime?)null)
- .Max();
- }
- }
- catch (TvDbServerException e)
- {
- _logger.LogError(e, "Failed to find series end date for series {TvdbId}", tvdbSeries.Id);
- }
- }
- }
-
- private static void MapActorsToResult(MetadataResult<Series> result, IEnumerable<Actor> actors)
- {
- foreach (Actor actor in actors)
- {
- var personInfo = new PersonInfo
- {
- Type = PersonType.Actor,
- Name = (actor.Name ?? string.Empty).Trim(),
- Role = actor.Role,
- SortOrder = actor.SortOrder
- };
-
- if (!string.IsNullOrEmpty(actor.Image))
- {
- personInfo.ImageUrl = TvdbUtils.BannerUrl + actor.Image;
- }
-
- if (!string.IsNullOrWhiteSpace(personInfo.Name))
- {
- result.AddPerson(personInfo);
- }
- }
- }
-
- public string Name => "TheTVDB";
-
- public async Task Identify(SeriesInfo info)
- {
- if (!string.IsNullOrWhiteSpace(info.GetProviderId(MetadataProvider.Tvdb)))
- {
- return;
- }
-
- var srch = await FindSeries(info.Name, info.Year, info.MetadataLanguage, CancellationToken.None)
- .ConfigureAwait(false);
-
- var entry = srch.FirstOrDefault();
-
- if (entry != null)
- {
- var id = entry.GetProviderId(MetadataProvider.Tvdb);
- info.SetProviderId(MetadataProvider.Tvdb, id);
- }
- }
-
- public int Order => 0;
-
- public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
- {
- return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken);
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs b/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs
deleted file mode 100644
index 37a8d04a6..000000000
--- a/MediaBrowser.Providers/Plugins/TheTvdb/TvdbUtils.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-#pragma warning disable CS1591
-
-using System;
-using MediaBrowser.Model.Entities;
-
-namespace MediaBrowser.Providers.Plugins.TheTvdb
-{
- public static class TvdbUtils
- {
- public const string TvdbApiKey = "OG4V3YJ3FAP7FP2K";
- public const string TvdbBaseUrl = "https://www.thetvdb.com/";
- public const string TvdbImageBaseUrl = "https://www.thetvdb.com";
- public const string BannerUrl = TvdbImageBaseUrl + "/banners/";
-
- public static ImageType GetImageTypeFromKeyType(string keyType)
- {
- switch (keyType.ToLowerInvariant())
- {
- case "poster":
- case "season": return ImageType.Primary;
- case "series":
- case "seasonwide": return ImageType.Banner;
- case "fanart": return ImageType.Backdrop;
- default: throw new ArgumentException($"Invalid or unknown keytype: {keyType}", nameof(keyType));
- }
- }
-
- public static string NormalizeLanguage(string language)
- {
- if (string.IsNullOrWhiteSpace(language))
- {
- return null;
- }
-
- // pt-br is just pt to tvdb
- return language.Split('-')[0].ToLowerInvariant();
- }
- }
-}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
index 3984e4953..bcf9459ef 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
@@ -129,6 +129,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
.GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage), cancellationToken)
.ConfigureAwait(false);
+ if (movieResult == null)
+ {
+ return new MetadataResult<Movie>();
+ }
+
var movie = new Movie
{
Name = movieResult.Title ?? movieResult.OriginalTitle,
@@ -266,7 +271,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
}
}
-
if (movieResult.Videos?.Results != null)
{
var trailers = new List<MediaUrl>();
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
index b754a0795..0e8a5baab 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs
@@ -98,7 +98,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (preferredLanguage.Length == 5) // like en-US
{
- // Currenty, TMDB supports 2-letter language codes only
+ // Currently, TMDB supports 2-letter language codes only
// They are planning to change this in the future, thus we're
// supplying both codes if we're having a 5-letter code.
languages.Add(preferredLanguage.Substring(0, 2));
diff --git a/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs b/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs
deleted file mode 100644
index 40c5f2d78..000000000
--- a/MediaBrowser.Providers/TV/TvdbEpisodeExternalId.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-#pragma warning disable CS1591
-
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using MediaBrowser.Providers.Plugins.TheTvdb;
-
-namespace MediaBrowser.Providers.TV
-{
- public class TvdbEpisodeExternalId : IExternalId
- {
- /// <inheritdoc />
- public string ProviderName => "TheTVDB";
-
- /// <inheritdoc />
- public string Key => MetadataProvider.Tvdb.ToString();
-
- /// <inheritdoc />
- public ExternalIdMediaType? Type => ExternalIdMediaType.Episode;
-
- /// <inheritdoc />
- public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=episode&id={0}";
-
- /// <inheritdoc />
- public bool Supports(IHasProviderIds item) => item is Episode;
- }
-}
diff --git a/MediaBrowser.Providers/TV/TvdbExternalId.cs b/MediaBrowser.Providers/TV/TvdbExternalId.cs
deleted file mode 100644
index 4c54de9f8..000000000
--- a/MediaBrowser.Providers/TV/TvdbExternalId.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-#pragma warning disable CS1591
-
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using MediaBrowser.Providers.Plugins.TheTvdb;
-
-namespace MediaBrowser.Providers.TV
-{
- public class TvdbExternalId : IExternalId
- {
- /// <inheritdoc />
- public string ProviderName => "TheTVDB";
-
- /// <inheritdoc />
- public string Key => MetadataProvider.Tvdb.ToString();
-
- /// <inheritdoc />
- public ExternalIdMediaType? Type => null;
-
- /// <inheritdoc />
- public string UrlFormatString => TvdbUtils.TvdbBaseUrl + "?tab=series&id={0}";
-
- /// <inheritdoc />
- public bool Supports(IHasProviderIds item) => item is Series;
- }
-}
diff --git a/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs b/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs
deleted file mode 100644
index 807ebb3ee..000000000
--- a/MediaBrowser.Providers/TV/TvdbSeasonExternalId.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-#pragma warning disable CS1591
-
-using MediaBrowser.Controller.Entities.TV;
-using MediaBrowser.Controller.Providers;
-using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Providers;
-using MediaBrowser.Providers.Plugins.TheTvdb;
-
-namespace MediaBrowser.Providers.TV
-{
- public class TvdbSeasonExternalId : IExternalId
- {
- /// <inheritdoc />
- public string ProviderName => "TheTVDB";
-
- /// <inheritdoc />
- public string Key => MetadataProvider.Tvdb.ToString();
-
- /// <inheritdoc />
- public ExternalIdMediaType? Type => ExternalIdMediaType.Season;
-
- /// <inheritdoc />
- public string UrlFormatString => null;
-
- /// <inheritdoc />
- public bool Supports(IHasProviderIds item) => item is Season;
- }
-}
diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs
index c9f314af9..3cb18e424 100644
--- a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs
+++ b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs
@@ -4,7 +4,6 @@ using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Providers;
-using MediaBrowser.Providers.Plugins.TheTvdb;
namespace MediaBrowser.Providers.TV
{
diff --git a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
index 9cc0344c1..bce4cf009 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs
@@ -134,7 +134,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (!string.IsNullOrWhiteSpace(val))
{
- // int.TryParse is local aware, so it can be probamatic, force us culture
+ // int.TryParse is local aware, so it can be problematic, force us culture
if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval))
{
item.AirsBeforeEpisodeNumber = rval;
@@ -150,7 +150,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (!string.IsNullOrWhiteSpace(val))
{
- // int.TryParse is local aware, so it can be probamatic, force us culture
+ // int.TryParse is local aware, so it can be problematic, force us culture
if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval))
{
item.AirsAfterSeasonNumber = rval;
@@ -166,7 +166,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (!string.IsNullOrWhiteSpace(val))
{
- // int.TryParse is local aware, so it can be probamatic, force us culture
+ // int.TryParse is local aware, so it can be problematic, force us culture
if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval))
{
item.AirsBeforeSeasonNumber = rval;
@@ -182,7 +182,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (!string.IsNullOrWhiteSpace(val))
{
- // int.TryParse is local aware, so it can be probamatic, force us culture
+ // int.TryParse is local aware, so it can be problematic, force us culture
if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval))
{
item.AirsBeforeSeasonNumber = rval;
@@ -198,7 +198,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers
if (!string.IsNullOrWhiteSpace(val))
{
- // int.TryParse is local aware, so it can be probamatic, force us culture
+ // int.TryParse is local aware, so it can be problematic, force us culture
if (int.TryParse(val, NumberStyles.Integer, UsCulture, out var rval))
{
item.AirsBeforeEpisodeNumber = rval;
diff --git a/MediaBrowser.sln b/MediaBrowser.sln
index 25402aee1..5a807372d 100644
--- a/MediaBrowser.sln
+++ b/MediaBrowser.sln
@@ -1,6 +1,6 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio 15
-VisualStudioVersion = 15.0.26730.3
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.30503.244
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server", "Jellyfin.Server\Jellyfin.Server.csproj", "{07E39F42-A2C6-4B32-AF8C-725F957A73FF}"
EndProject
@@ -66,12 +66,22 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Data", "Jellyfin.D
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Server.Implementations", "Jellyfin.Server.Implementations\Jellyfin.Server.Implementations.csproj", "{DAE48069-6D86-4BA6-B148-D1D49B6DDA52}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking", "Jellyfin.Networking\Jellyfin.Networking.csproj", "{0A3FCC4D-C714-4072-B90F-E374A15F9FF9}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Networking.Tests", "tests\Jellyfin.Networking.Tests\NetworkTesting\Jellyfin.Networking.Tests.csproj", "{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Dlna.Tests", "tests\Jellyfin.Dlna.Tests\Jellyfin.Dlna.Tests.csproj", "{B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.Build.0 = Release|Any CPU
{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17E1F4E6-8ABD-4FE5-9ECF-43D4B6087BA2}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -132,10 +142,6 @@ Global
{960295EE-4AF4-4440-A525-B4C295B01A61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{960295EE-4AF4-4440-A525-B4C295B01A61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{960295EE-4AF4-4440-A525-B4C295B01A61}.Release|Any CPU.Build.0 = Release|Any CPU
- {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {07E39F42-A2C6-4B32-AF8C-725F957A73FF}.Release|Any CPU.Build.0 = Release|Any CPU
{154872D9-6C12-4007-96E3-8F70A58386CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{154872D9-6C12-4007-96E3-8F70A58386CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{154872D9-6C12-4007-96E3-8F70A58386CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -176,10 +182,32 @@ Global
{DAE48069-6D86-4BA6-B148-D1D49B6DDA52}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DAE48069-6D86-4BA6-B148-D1D49B6DDA52}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DAE48069-6D86-4BA6-B148-D1D49B6DDA52}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0A3FCC4D-C714-4072-B90F-E374A15F9FF9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {DF194677-DFD3-42AF-9F75-D44D5A416478} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {28464062-0939-4AA7-9F7B-24DDDA61A7C0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {3998657B-1CCC-49DD-A19F-275DC8495F57} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {462584F7-5023-4019-9EAC-B98CA458C0A0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ {B8AE4B9D-E8D3-4B03-A95E-7FD8CECECC50} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
EndGlobalSection
@@ -201,12 +229,4 @@ Global
$0.DotNetNamingPolicy = $2
$2.DirectoryNamespaceAssociation = PrefixedHierarchical
EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {DF194677-DFD3-42AF-9F75-D44D5A416478} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {28464062-0939-4AA7-9F7B-24DDDA61A7C0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {3998657B-1CCC-49DD-A19F-275DC8495F57} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {A2FD0A10-8F62-4F9D-B171-FFDF9F0AFA9D} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {2E3A1B4B-4225-4AAA-8B29-0181A84E7AEE} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- {462584F7-5023-4019-9EAC-B98CA458C0A0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
- EndGlobalSection
EndGlobal
diff --git a/RSSDP/DisposableManagedObjectBase.cs b/RSSDP/DisposableManagedObjectBase.cs
index 745ec359c..7d6a471f9 100644
--- a/RSSDP/DisposableManagedObjectBase.cs
+++ b/RSSDP/DisposableManagedObjectBase.cs
@@ -5,7 +5,7 @@ using System.Text;
namespace Rssdp.Infrastructure
{
/// <summary>
- /// Correclty implements the <see cref="IDisposable"/> interface and pattern for an object containing only managed resources, and adds a few common niceities not on the interface such as an <see cref="IsDisposed"/> property.
+ /// Correctly implements the <see cref="IDisposable"/> interface and pattern for an object containing only managed resources, and adds a few common niceties not on the interface such as an <see cref="IsDisposed"/> property.
/// </summary>
public abstract class DisposableManagedObjectBase : IDisposable
{
@@ -61,10 +61,10 @@ namespace Rssdp.Infrastructure
/// Disposes this object instance and all internally managed resources.
/// </summary>
/// <remarks>
- /// <para>Sets the <see cref="IsDisposed"/> property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behaviour of derived classes.</para>
+ /// <para>Sets the <see cref="IsDisposed"/> property to true. Does not explicitly throw an exception if called multiple times, but makes no promises about behavior of derived classes.</para>
/// </remarks>
/// <seealso cref="IsDisposed"/>
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1063:ImplementIDisposableCorrectly", Justification = "We do exactly as asked, but CA doesn't seem to like us also setting the IsDisposed property. Too bad, it's a good idea and shouldn't cause an exception or anything likely to interfer with the dispose process.")]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Design", "CA1063:ImplementIDisposableCorrectly", Justification = "We do exactly as asked, but CA doesn't seem to like us also setting the IsDisposed property. Too bad, it's a good idea and shouldn't cause an exception or anything likely to interfere with the dispose process.")]
public void Dispose()
{
IsDisposed = true;
diff --git a/RSSDP/HttpParserBase.cs b/RSSDP/HttpParserBase.cs
index 11202940e..c56249523 100644
--- a/RSSDP/HttpParserBase.cs
+++ b/RSSDP/HttpParserBase.cs
@@ -105,7 +105,7 @@ namespace Rssdp.Infrastructure
var headerName = line.Substring(0, headerKeySeparatorIndex).Trim();
var headerValue = line.Substring(headerKeySeparatorIndex + 1).Trim();
- // Not sure how to determine where request headers and and content headers begin,
+ // Not sure how to determine where request headers and content headers begin,
// at least not without a known set of headers (general headers first the content headers)
// which seems like a bad way of doing it. So we'll assume if it's a known content header put it there
// else use request headers.
diff --git a/RSSDP/ISsdpDeviceLocator.cs b/RSSDP/ISsdpDeviceLocator.cs
index 413055643..4df166cd2 100644
--- a/RSSDP/ISsdpDeviceLocator.cs
+++ b/RSSDP/ISsdpDeviceLocator.cs
@@ -52,7 +52,7 @@ namespace Rssdp.Infrastructure
}
/// <summary>
- /// Aynchronously performs a search for all devices using the default search timeout, and returns an awaitable task that can be used to retrieve the results.
+ /// Asynchronously performs a search for all devices using the default search timeout, and returns an awaitable task that can be used to retrieve the results.
/// </summary>
/// <returns>A task whose result is an <see cref="System.Collections.Generic.IEnumerable{T}"/> of <see cref="DiscoveredSsdpDevice" /> instances, representing all found devices.</returns>
System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<DiscoveredSsdpDevice>> SearchAsync();
@@ -83,7 +83,7 @@ namespace Rssdp.Infrastructure
/// </param>
/// <param name="searchWaitTime">The amount of time to wait for network responses to the search request. Longer values will likely return more devices, but increase search time. A value between 1 and 5 is recommended by the UPnP 1.1 specification. Specify TimeSpan.Zero to return only devices already in the cache.</param>
/// <remarks>
- /// <para>By design RSSDP does not support 'publishing services' as it is intended for use with non-standard UPnP devices that don't publish UPnP style services. However, it is still possible to use RSSDP to search for devices implemetning these services if you know the service type.</para>
+ /// <para>By design RSSDP does not support 'publishing services' as it is intended for use with non-standard UPnP devices that don't publish UPnP style services. However, it is still possible to use RSSDP to search for devices implementing these services if you know the service type.</para>
/// </remarks>
/// <returns>A task whose result is an <see cref="System.Collections.Generic.IEnumerable{T}"/> of <see cref="DiscoveredSsdpDevice" /> instances, representing all found devices.</returns>
System.Threading.Tasks.Task<System.Collections.Generic.IEnumerable<DiscoveredSsdpDevice>> SearchAsync(string searchTarget, TimeSpan searchWaitTime);
diff --git a/RSSDP/RSSDP.csproj b/RSSDP/RSSDP.csproj
index d0962e82c..c64ee9389 100644
--- a/RSSDP/RSSDP.csproj
+++ b/RSSDP/RSSDP.csproj
@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
+ <ProjectReference Include="..\Jellyfin.Networking\Jellyfin.Networking.csproj" />
<ProjectReference Include="..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
</ItemGroup>
diff --git a/RSSDP/SsdpCommunicationsServer.cs b/RSSDP/SsdpCommunicationsServer.cs
index a4be32e7d..8f1f0fa61 100644
--- a/RSSDP/SsdpCommunicationsServer.cs
+++ b/RSSDP/SsdpCommunicationsServer.cs
@@ -352,7 +352,7 @@ namespace Rssdp.Infrastructure
if (_enableMultiSocketBinding)
{
- foreach (var address in _networkManager.GetLocalIpAddresses())
+ foreach (var address in _networkManager.GetInternalBindAddresses())
{
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
@@ -362,7 +362,7 @@ namespace Rssdp.Infrastructure
try
{
- sockets.Add(_SocketFactory.CreateSsdpUdpSocket(address, _LocalPort));
+ sockets.Add(_SocketFactory.CreateSsdpUdpSocket(address.Address, _LocalPort));
}
catch (Exception ex)
{
diff --git a/RSSDP/SsdpDevicePublisher.cs b/RSSDP/SsdpDevicePublisher.cs
index 1a8577d8d..c9e795d56 100644
--- a/RSSDP/SsdpDevicePublisher.cs
+++ b/RSSDP/SsdpDevicePublisher.cs
@@ -102,7 +102,7 @@ namespace Rssdp.Infrastructure
/// <param name="device">The <see cref="SsdpDevice"/> instance to add.</param>
/// <exception cref="ArgumentNullException">Thrown if the <paramref name="device"/> argument is null.</exception>
/// <exception cref="InvalidOperationException">Thrown if the <paramref name="device"/> contains property values that are not acceptable to the UPnP 1.0 specification.</exception>
- [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capture task to local variable supresses compiler warning, but task is not really needed.")]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capture task to local variable suppresses compiler warning, but task is not really needed.")]
public void AddDevice(SsdpRootDevice device)
{
if (device == null)
@@ -180,7 +180,7 @@ namespace Rssdp.Infrastructure
/// </summary>
/// <remarks>
/// <para>Enabling this option will cause devices to show up in Microsoft Windows Explorer's network screens (if discovery is enabled etc.). Windows Explorer appears to search only for pnp:rootdeivce and not upnp:rootdevice.</para>
- /// <para>If false, the system will only use upnp:rootdevice for notifiation broadcasts and and search responses, which is correct according to the UPnP/SSDP spec.</para>
+ /// <para>If false, the system will only use upnp:rootdevice for notification broadcasts and and search responses, which is correct according to the UPnP/SSDP spec.</para>
/// </remarks>
public bool SupportPnpRootDevice
{
@@ -300,17 +300,15 @@ namespace Rssdp.Infrastructure
foreach (var device in deviceList)
{
- if (!_sendOnlyMatchedHost ||
- _networkManager.IsInSameSubnet(device.ToRootDevice().Address, remoteEndPoint.Address, device.ToRootDevice().SubnetMask))
+ var root = device.ToRootDevice();
+ var source = new IPNetAddress(root.Address, root.PrefixLength);
+ var destination = new IPNetAddress(remoteEndPoint.Address, root.PrefixLength);
+ if (!_sendOnlyMatchedHost || source.NetworkAddress.Equals(destination.NetworkAddress))
{
SendDeviceSearchResponses(device, remoteEndPoint, receivedOnlocalIpAddress, cancellationToken);
}
}
}
- else
- {
- // WriteTrace(String.Format("Sending 0 search responses."));
- }
});
}
diff --git a/RSSDP/SsdpRootDevice.cs b/RSSDP/SsdpRootDevice.cs
index 8937ec331..5ecb1f86f 100644
--- a/RSSDP/SsdpRootDevice.cs
+++ b/RSSDP/SsdpRootDevice.cs
@@ -25,7 +25,7 @@ namespace Rssdp
/// Specifies how long clients can cache this device's details for. Optional but defaults to <see cref="TimeSpan.Zero"/> which means no-caching. Recommended value is half an hour.
/// </summary>
/// <remarks>
- /// <para>Specifiy <see cref="TimeSpan.Zero"/> to indicate no caching allowed.</para>
+ /// <para>Specify <see cref="TimeSpan.Zero"/> to indicate no caching allowed.</para>
/// <para>Also used to specify how often to rebroadcast alive notifications.</para>
/// <para>The UPnP/SSDP specifications indicate this should not be less than 1800 seconds (half an hour), but this is not enforced by this library.</para>
/// </remarks>
@@ -45,12 +45,12 @@ namespace Rssdp
public IPAddress Address { get; set; }
/// <summary>
- /// Gets or sets the SubnetMask used to check if the received message from same interface with this device/tree. Required.
+ /// Gets or sets the prefix length used to check if the received message from same interface with this device/tree. Required.
/// </summary>
- public IPAddress SubnetMask { get; set; }
+ public byte PrefixLength { get; set; }
/// <summary>
- /// The base URL to use for all relative url's provided in other propertise (and those of child devices). Optional.
+ /// The base URL to use for all relative url's provided in other properties (and those of child devices). Optional.
/// </summary>
/// <remarks>
/// <para>Defines the base URL. Used to construct fully-qualified URLs. All relative URLs that appear elsewhere in the description are combined with this base URL. If URLBase is empty or not given, the base URL is the URL from which the device description was retrieved (which is the preferred implementation; use of URLBase is no longer recommended). Specified by UPnP vendor. Single URL.</para>
diff --git a/benches/Jellyfin.Common.Benches/HexDecodeBenches.cs b/benches/Jellyfin.Common.Benches/HexDecodeBenches.cs
deleted file mode 100644
index d9a107b69..000000000
--- a/benches/Jellyfin.Common.Benches/HexDecodeBenches.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using System;
-using System.Globalization;
-using BenchmarkDotNet.Attributes;
-using BenchmarkDotNet.Running;
-using MediaBrowser.Common;
-
-namespace Jellyfin.Common.Benches
-{
- [MemoryDiagnoser]
- public class HexDecodeBenches
- {
- private string _data;
-
- [Params(0, 10, 100, 1000, 10000, 1000000)]
- public int N { get; set; }
-
- [GlobalSetup]
- public void GlobalSetup()
- {
- var bytes = new byte[N];
- new Random(42).NextBytes(bytes);
- _data = Hex.Encode(bytes);
- }
-
- [Benchmark]
- public byte[] Decode() => Hex.Decode(_data);
-
- [Benchmark]
- public byte[] DecodeSubString() => DecodeSubString(_data);
-
- private static byte[] DecodeSubString(string str)
- {
- byte[] bytes = new byte[str.Length / 2];
- for (int i = 0; i < str.Length; i += 2)
- {
- bytes[i / 2] = byte.Parse(
- str.Substring(i, 2),
- NumberStyles.HexNumber,
- CultureInfo.InvariantCulture);
- }
-
- return bytes;
- }
- }
-}
diff --git a/benches/Jellyfin.Common.Benches/HexEncodeBenches.cs b/benches/Jellyfin.Common.Benches/HexEncodeBenches.cs
deleted file mode 100644
index 7abf93c51..000000000
--- a/benches/Jellyfin.Common.Benches/HexEncodeBenches.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using System;
-using BenchmarkDotNet.Attributes;
-using BenchmarkDotNet.Running;
-using MediaBrowser.Common;
-
-namespace Jellyfin.Common.Benches
-{
- [MemoryDiagnoser]
- public class HexEncodeBenches
- {
- private byte[] _data;
-
- [Params(0, 10, 100, 1000, 10000, 1000000)]
- public int N { get; set; }
-
- [GlobalSetup]
- public void GlobalSetup()
- {
- _data = new byte[N];
- new Random(42).NextBytes(_data);
- }
-
- [Benchmark]
- public string HexEncode() => Hex.Encode(_data);
-
- [Benchmark]
- public string BitConverterToString() => BitConverter.ToString(_data);
-
- [Benchmark]
- public string BitConverterToStringWithReplace() => BitConverter.ToString(_data).Replace("-", "");
- }
-}
diff --git a/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj b/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj
deleted file mode 100644
index c564e86e9..000000000
--- a/benches/Jellyfin.Common.Benches/Jellyfin.Common.Benches.csproj
+++ /dev/null
@@ -1,16 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-
- <PropertyGroup>
- <OutputType>Exe</OutputType>
- <TargetFramework>net5.0</TargetFramework>
- </PropertyGroup>
-
- <ItemGroup>
- <PackageReference Include="BenchmarkDotNet" Version="0.12.0" />
- </ItemGroup>
-
- <ItemGroup>
- <ProjectReference Include="../../MediaBrowser.Common/MediaBrowser.Common.csproj" />
- </ItemGroup>
-
-</Project>
diff --git a/benches/Jellyfin.Common.Benches/Program.cs b/benches/Jellyfin.Common.Benches/Program.cs
deleted file mode 100644
index b218b0dc1..000000000
--- a/benches/Jellyfin.Common.Benches/Program.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using System;
-using BenchmarkDotNet.Running;
-
-namespace Jellyfin.Common.Benches
-{
- public static class Program
- {
- public static void Main(string[] args)
- {
- _ = BenchmarkRunner.Run<HexEncodeBenches>();
- _ = BenchmarkRunner.Run<HexDecodeBenches>();
- }
- }
-}
diff --git a/bump_version b/bump_version
index d2de5a0bd..8012ec07f 100755
--- a/bump_version
+++ b/bump_version
@@ -49,7 +49,6 @@ sed -i "s/${old_version_sed}/${new_version}/g" ${build_file}
# update nuget package version
for subproject in ${jellyfin_subprojects[@]}; do
-do
echo ${subproject}
# Parse the version from the *.csproj file
old_version="$(
diff --git a/debian/bin/restart.sh b/debian/bin/restart.sh
index 9b64b6d72..34fce0670 100755
--- a/debian/bin/restart.sh
+++ b/debian/bin/restart.sh
@@ -24,13 +24,13 @@ cmd="$( get_service_command )"
echo "Detected service control platform '$cmd'; using it to restart Jellyfin..."
case $cmd in
'systemctl')
- echo "sleep 2; /usr/bin/sudo $( which systemctl ) restart jellyfin" | at now
+ echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now
;;
'service')
- echo "sleep 2; /usr/bin/sudo $( which service ) jellyfin restart" | at now
+ echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now
;;
'sysv')
- echo "sleep 2; /usr/bin/sudo /etc/init.d/jellyfin restart" | at now
+ echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now
;;
esac
exit 0
diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec
index 13305488e..197126ee5 100644
--- a/fedora/jellyfin.spec
+++ b/fedora/jellyfin.spec
@@ -40,7 +40,7 @@ Jellyfin is a free software media system that puts you in control of managing an
Summary: The Free Software Media System Server backend
Requires(pre): shadow-utils
Requires: ffmpeg
-Requires: libcurl, fontconfig, freetype, openssl, glibc libicu
+Requires: libcurl, fontconfig, freetype, openssl, glibc, libicu, at
%description server
The Jellyfin media server backend.
diff --git a/fedora/restart.sh b/fedora/restart.sh
index 9e53efecd..34fce0670 100755
--- a/fedora/restart.sh
+++ b/fedora/restart.sh
@@ -24,13 +24,13 @@ cmd="$( get_service_command )"
echo "Detected service control platform '$cmd'; using it to restart Jellyfin..."
case $cmd in
'systemctl')
- echo "sleep 2; /usr/bin/sudo $( which systemctl ) restart jellyfin" | at now
+ echo "sleep 0.5; /usr/bin/sudo $( which systemctl ) start jellyfin" | at now
;;
'service')
- echo "sleep 2; /usr/bin/sudo $( which service ) jellyfin restart" | at now
+ echo "sleep 0.5; /usr/bin/sudo $( which service ) jellyfin start" | at now
;;
'sysv')
- echo "sleep 2; /usr/bin/sudo /etc/init.d/jellyfin restart" | at now
+ echo "sleep 0.5; /usr/bin/sudo /etc/init.d/jellyfin start" | at now
;;
esac
exit 0
diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
index 90c491666..ee20cc573 100644
--- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs
@@ -69,7 +69,7 @@ namespace Jellyfin.Api.Tests.Auth
}
[Fact]
- public async Task HandleAuthenticateAsyncShouldFailOnAuthenticationException()
+ public async Task HandleAuthenticateAsyncShouldProvideNoResultOnAuthenticationException()
{
var errorMessage = _fixture.Create<string>();
@@ -81,7 +81,7 @@ namespace Jellyfin.Api.Tests.Auth
var authenticateResult = await _sut.AuthenticateAsync();
Assert.False(authenticateResult.Succeeded);
- Assert.Equal(errorMessage, authenticateResult.Failure?.Message);
+ Assert.True(authenticateResult.None);
}
[Fact]
diff --git a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
index 14eed30e0..7c552ec06 100644
--- a/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
+++ b/tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
@@ -22,7 +22,7 @@
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
<PackageReference Include="coverlet.collector" Version="1.3.0" />
- <PackageReference Include="Moq" Version="4.15.1" />
+ <PackageReference Include="Moq" Version="4.15.2" />
</ItemGroup>
<!-- Code Analyzers -->
diff --git a/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs
index bd3d35687..54f8eb225 100644
--- a/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs
+++ b/tests/Jellyfin.Api.Tests/JellyfinApplicationFactory.cs
@@ -3,8 +3,6 @@ using System.Collections.Concurrent;
using System.IO;
using Emby.Server.Implementations;
using Emby.Server.Implementations.IO;
-using Emby.Server.Implementations.Networking;
-using Jellyfin.Drawing.Skia;
using Jellyfin.Server;
using MediaBrowser.Common;
using Microsoft.AspNetCore.Hosting;
@@ -80,7 +78,6 @@ namespace Jellyfin.Api.Tests
loggerFactory,
commandLineOpts,
new ManagedFileSystem(loggerFactory.CreateLogger<ManagedFileSystem>(), appPaths),
- new NetworkManager(loggerFactory.CreateLogger<NetworkManager>()),
serviceCollection);
_disposableComponents.Add(appHost);
appHost.Init();
diff --git a/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs
new file mode 100644
index 000000000..938d19a15
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/ModelBinders/PipeDelimitedArrayModelBinderTests.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading.Tasks;
+using Jellyfin.Api.ModelBinders;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Primitives;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.ModelBinders
+{
+ public sealed class PipeDelimitedArrayModelBinderTests
+ {
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedStringArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<string> queryParamValues = new[] { "lol", "xd" };
+ var queryParamString = "lol|xd";
+ var queryParamType = typeof(string[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<string>?)bindingContextMock.Object?.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidDelimitedIntArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<int> queryParamValues = new[] { 42, 0 };
+ var queryParamString = "42|0";
+ var queryParamType = typeof(int[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<int>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much };
+ var queryParamString = "How|Much";
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidPipeDelimitedEnumArrayQueryWithDoublePipes()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much };
+ var queryParamString = "How||Much";
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsValidEnumArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<TestType> queryParamValues = new[] { TestType.How, TestType.Much };
+ var queryParamString1 = "How";
+ var queryParamString2 = "Much";
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues>
+ {
+ { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) },
+ }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_CorrectlyBindsEmptyEnumArrayQuery()
+ {
+ var queryParamName = "test";
+ IReadOnlyList<TestType> queryParamValues = Array.Empty<TestType>();
+ var queryParamType = typeof(TestType[]);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues>
+ {
+ { queryParamName, new StringValues(value: null) },
+ }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Equal((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model, queryParamValues);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_EnumArrayQuery_BindValidOnly()
+ {
+ var queryParamName = "test";
+ var queryParamString = "🔥|😢";
+ var queryParamType = typeof(IReadOnlyList<TestType>);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues> { { queryParamName, new StringValues(queryParamString) } }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Empty((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model);
+ }
+
+ [Fact]
+ public async Task BindModelAsync_EnumArrayQuery_BindValidOnly_2()
+ {
+ var queryParamName = "test";
+ var queryParamString1 = "How";
+ var queryParamString2 = "😱";
+ var queryParamType = typeof(IReadOnlyList<TestType>);
+
+ var modelBinder = new PipeDelimitedArrayModelBinder(new NullLogger<PipeDelimitedArrayModelBinder>());
+
+ var valueProvider = new QueryStringValueProvider(
+ new BindingSource(string.Empty, string.Empty, false, false),
+ new QueryCollection(new Dictionary<string, StringValues>
+ {
+ { queryParamName, new StringValues(new[] { queryParamString1, queryParamString2 }) },
+ }),
+ CultureInfo.InvariantCulture);
+ var bindingContextMock = new Mock<ModelBindingContext>();
+ bindingContextMock.Setup(b => b.ValueProvider).Returns(valueProvider);
+ bindingContextMock.Setup(b => b.ModelName).Returns(queryParamName);
+ bindingContextMock.Setup(b => b.ModelType).Returns(queryParamType);
+ bindingContextMock.SetupProperty(b => b.Result);
+
+ await modelBinder.BindModelAsync(bindingContextMock.Object);
+ Assert.True(bindingContextMock.Object.Result.IsModelSet);
+ Assert.Single((IReadOnlyList<TestType>?)bindingContextMock.Object.Result.Model);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Common.Tests/HexTests.cs b/tests/Jellyfin.Common.Tests/HexTests.cs
deleted file mode 100644
index 5b578d38c..000000000
--- a/tests/Jellyfin.Common.Tests/HexTests.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using MediaBrowser.Common;
-using Xunit;
-
-namespace Jellyfin.Common.Tests
-{
- public class HexTests
- {
- [Theory]
- [InlineData("")]
- [InlineData("00")]
- [InlineData("01")]
- [InlineData("000102030405060708090a0b0c0d0e0f")]
- [InlineData("0123456789abcdef")]
- public void RoundTripTest(string data)
- {
- Assert.Equal(data, Hex.Encode(Hex.Decode(data)));
- }
- }
-}
diff --git a/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs b/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs
index d9e66d677..3c94db491 100644
--- a/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs
+++ b/tests/Jellyfin.Common.Tests/Json/JsonGuidConverterTests.cs
@@ -5,28 +5,48 @@ using Xunit;
namespace Jellyfin.Common.Tests.Extensions
{
- public static class JsonGuidConverterTests
+ public class JsonGuidConverterTests
{
+ private readonly JsonSerializerOptions _options;
+
+ public JsonGuidConverterTests()
+ {
+ _options = new JsonSerializerOptions();
+ _options.Converters.Add(new JsonGuidConverter());
+ }
+
[Fact]
- public static void Deserialize_Valid_Success()
+ public void Deserialize_Valid_Success()
{
- var options = new JsonSerializerOptions();
- options.Converters.Add(new JsonGuidConverter());
- Guid value = JsonSerializer.Deserialize<Guid>(@"""a852a27afe324084ae66db579ee3ee18""", options);
+ Guid value = JsonSerializer.Deserialize<Guid>(@"""a852a27afe324084ae66db579ee3ee18""", _options);
Assert.Equal(new Guid("a852a27afe324084ae66db579ee3ee18"), value);
+ }
- value = JsonSerializer.Deserialize<Guid>(@"""e9b2dcaa-529c-426e-9433-5e9981f27f2e""", options);
+ [Fact]
+ public void Deserialize_ValidDashed_Success()
+ {
+ Guid value = JsonSerializer.Deserialize<Guid>(@"""e9b2dcaa-529c-426e-9433-5e9981f27f2e""", _options);
Assert.Equal(new Guid("e9b2dcaa-529c-426e-9433-5e9981f27f2e"), value);
}
[Fact]
- public static void Roundtrip_Valid_Success()
+ public void Roundtrip_Valid_Success()
{
- var options = new JsonSerializerOptions();
- options.Converters.Add(new JsonGuidConverter());
Guid guid = new Guid("a852a27afe324084ae66db579ee3ee18");
- string value = JsonSerializer.Serialize(guid, options);
- Assert.Equal(guid, JsonSerializer.Deserialize<Guid>(value, options));
+ string value = JsonSerializer.Serialize(guid, _options);
+ Assert.Equal(guid, JsonSerializer.Deserialize<Guid>(value, _options));
+ }
+
+ [Fact]
+ public void Deserialize_Null_EmptyGuid()
+ {
+ Assert.Equal(Guid.Empty, JsonSerializer.Deserialize<Guid>("null", _options));
+ }
+
+ [Fact]
+ public void Serialize_EmptyGuid_Null()
+ {
+ Assert.Equal("null", JsonSerializer.Serialize(Guid.Empty, _options));
}
}
}
diff --git a/tests/Jellyfin.Common.Tests/PasswordHashTests.cs b/tests/Jellyfin.Common.Tests/PasswordHashTests.cs
index 46926f4f8..c4422bd10 100644
--- a/tests/Jellyfin.Common.Tests/PasswordHashTests.cs
+++ b/tests/Jellyfin.Common.Tests/PasswordHashTests.cs
@@ -1,3 +1,4 @@
+using System;
using MediaBrowser.Common;
using MediaBrowser.Common.Cryptography;
using Xunit;
@@ -16,8 +17,8 @@ namespace Jellyfin.Common.Tests
{
var pass = PasswordHash.Parse(passwordHash);
Assert.Equal(id, pass.Id);
- Assert.Equal(salt, Hex.Encode(pass.Salt, false));
- Assert.Equal(hash, Hex.Encode(pass.Hash, false));
+ Assert.Equal(salt, Convert.ToHexString(pass.Salt));
+ Assert.Equal(hash, Convert.ToHexString(pass.Hash));
}
[Theory]
diff --git a/tests/Jellyfin.Dlna.Tests/GetUuidTests.cs b/tests/Jellyfin.Dlna.Tests/GetUuidTests.cs
new file mode 100644
index 000000000..7655e3f7c
--- /dev/null
+++ b/tests/Jellyfin.Dlna.Tests/GetUuidTests.cs
@@ -0,0 +1,17 @@
+using Emby.Dlna.PlayTo;
+using Xunit;
+
+namespace Jellyfin.Dlna.Tests
+{
+ public static class GetUuidTests
+ {
+ [Theory]
+ [InlineData("uuid:fc4ec57e-b051-11db-88f8-0060085db3f6::urn:schemas-upnp-org:device:WANDevice:1", "fc4ec57e-b051-11db-88f8-0060085db3f6")]
+ [InlineData("uuid:IGD{8c80f73f-4ba0-45fa-835d-042505d052be}000000000000", "8c80f73f-4ba0-45fa-835d-042505d052be")]
+ [InlineData("uuid:IGD{8c80f73f-4ba0-45fa-835d-042505d052be}000000000000::urn:schemas-upnp-org:device:InternetGatewayDevice:1", "8c80f73f-4ba0-45fa-835d-042505d052be")]
+ [InlineData("uuid:00000000-0000-0000-0000-000000000000::upnp:rootdevice", "00000000-0000-0000-0000-000000000000")]
+ [InlineData("uuid:fc4ec57e-b051-11db-88f8-0060085db3f6", "fc4ec57e-b051-11db-88f8-0060085db3f6")]
+ public static void GetUuid_Valid_Success(string usn, string uuid)
+ => Assert.Equal(uuid, PlayToManager.GetUuid(usn));
+ }
+}
diff --git a/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj
new file mode 100644
index 000000000..f91db6744
--- /dev/null
+++ b/tests/Jellyfin.Dlna.Tests/Jellyfin.Dlna.Tests.csproj
@@ -0,0 +1,33 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net5.0</TargetFramework>
+ <IsPackable>false</IsPackable>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ <Nullable>enable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
+ <PackageReference Include="xunit" Version="2.4.1" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
+ <PackageReference Include="coverlet.collector" Version="1.3.0" />
+ </ItemGroup>
+
+ <!-- Code Analyzers -->
+ <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
+ <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
+ <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="All" />
+ <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="../../Emby.Dlna/Emby.Dlna.csproj" />
+ </ItemGroup>
+
+ <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <CodeAnalysisRuleSet>../jellyfin-tests.ruleset</CodeAnalysisRuleSet>
+ </PropertyGroup>
+
+</Project>
diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs
index 15cb5c72f..950899d7e 100644
--- a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs
@@ -47,6 +47,7 @@ namespace Jellyfin.Naming.Tests.Video
// FIXME: [InlineData("Robin Hood [Multi-Subs] [2018].mkv", "Robin Hood", 2018)]
[InlineData(@"3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv", "3.Days.to.Kill", 2014)] // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again
[InlineData("3 days to kill (2005).mkv", "3 days to kill", 2005)]
+ [InlineData(@"Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4", "Rain Man", 1988)]
[InlineData("My Movie 2013.12.09", "My Movie 2013.12.09", null)]
[InlineData("My Movie 2013-12-09", "My Movie 2013-12-09", null)]
[InlineData("My Movie 20131209", "My Movie 20131209", null)]
diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs
index 3bdafa84d..b6447a7a6 100644
--- a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs
@@ -145,6 +145,14 @@ namespace Jellyfin.Naming.Tests.Video
name: "Brave",
year: 2006)
};
+ yield return new object[]
+ {
+ new VideoFileInfo(
+ path: @"/server/Movies/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem/Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4",
+ container: "mp4",
+ name: "Rain Man",
+ year: 1988)
+ };
}
[Theory]
diff --git a/tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj
new file mode 100644
index 000000000..48b0b4c7d
--- /dev/null
+++ b/tests/Jellyfin.Networking.Tests/NetworkTesting/Jellyfin.Networking.Tests.csproj
@@ -0,0 +1,39 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
+ <PropertyGroup>
+ <ProjectGuid>{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6}</ProjectGuid>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <TargetFramework>net5.0</TargetFramework>
+ <IsPackable>false</IsPackable>
+ <Nullable>enable</Nullable>
+ <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
+ <PackageReference Include="xunit" Version="2.4.1" />
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1" />
+ <PackageReference Include="coverlet.collector" Version="1.3.0" />
+ <PackageReference Include="Moq" Version="4.15.2" />
+ </ItemGroup>
+
+ <!-- Code Analyzers-->
+ <ItemGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.8" PrivateAssets="All" />
+ <PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
+ <PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
+ </ItemGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\..\..\Emby.Server.Implementations\Emby.Server.Implementations.csproj" />
+ <ProjectReference Include="..\..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
+ </ItemGroup>
+ <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
+ <CodeAnalysisRuleSet>../../jellyfin-tests.ruleset</CodeAnalysisRuleSet>
+ </PropertyGroup>
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+ <DefineConstants>DEBUG</DefineConstants>
+ </PropertyGroup>
+</Project>
diff --git a/tests/Jellyfin.Networking.Tests/NetworkTesting/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkTesting/NetworkParseTests.cs
new file mode 100644
index 000000000..c350685af
--- /dev/null
+++ b/tests/Jellyfin.Networking.Tests/NetworkTesting/NetworkParseTests.cs
@@ -0,0 +1,519 @@
+using System;
+using System.Net;
+using Jellyfin.Networking.Configuration;
+using Jellyfin.Networking.Manager;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Common.Net;
+using Moq;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+using System.Collections.ObjectModel;
+
+namespace Jellyfin.Networking.Tests
+{
+ public class NetworkParseTests
+ {
+ /// <summary>
+ /// Tries to identify the string and return an object of that class.
+ /// </summary>
+ /// <param name="addr">String to parse.</param>
+ /// <param name="result">IPObject to return.</param>
+ /// <returns>True if the value parsed successfully.</returns>
+ private static bool TryParse(string addr, out IPObject result)
+ {
+ if (!string.IsNullOrEmpty(addr))
+ {
+ // Is it an IP address
+ if (IPNetAddress.TryParse(addr, out IPNetAddress nw))
+ {
+ result = nw;
+ return true;
+ }
+
+ if (IPHost.TryParse(addr, out IPHost h))
+ {
+ result = h;
+ return true;
+ }
+ }
+
+ result = IPNetAddress.None;
+ return false;
+ }
+
+ private static IConfigurationManager GetMockConfig(NetworkConfiguration conf)
+ {
+ var configManager = new Mock<IConfigurationManager>
+ {
+ CallBase = true
+ };
+ configManager.Setup(x => x.GetConfiguration(It.IsAny<string>())).Returns(conf);
+ return (IConfigurationManager)configManager.Object;
+ }
+
+ /// <summary>
+ /// Checks the ability to ignore interfaces
+ /// </summary>
+ /// <param name="interfaces">Mock network setup, in the format (IP address, interface index, interface name) : .... </param>
+ /// <param name="lan">LAN addresses.</param>
+ /// <param name="value">Bind addresses that are excluded.</param>
+ [Theory]
+ [InlineData("192.168.1.208/24,-16,eth16:200.200.200.200/24,11,eth11", "192.168.1.0/24;200.200.200.0/24", "[192.168.1.208/24,200.200.200.200/24]")]
+ [InlineData("192.168.1.208/24,-16,eth16:200.200.200.200/24,11,eth11", "192.168.1.0/24", "[192.168.1.208/24]")]
+ [InlineData("192.168.1.208/24,-16,vEthernet1:192.168.1.208/24,-16,vEthernet212;200.200.200.200/24,11,eth11", "192.168.1.0/24", "[192.168.1.208/24]")]
+ public void IgnoreVirtualInterfaces(string interfaces, string lan, string value)
+ {
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true,
+ LocalNetworkSubnets = lan?.Split(';') ?? throw new ArgumentNullException(nameof(lan))
+ };
+
+ NetworkManager.MockNetworkSettings = interfaces;
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ Assert.Equal(nm.GetInternalBindAddresses().AsString(), value);
+ }
+
+ /// <summary>
+ /// Check that the value given is in the network provided.
+ /// </summary>
+ /// <param name="network">Network address.</param>
+ /// <param name="value">Value to check.</param>
+ [Theory]
+ [InlineData("192.168.10.0/24, !192.168.10.60/32", "192.168.10.60")]
+ public void IsInNetwork(string network, string value)
+ {
+ if (network == null)
+ {
+ throw new ArgumentNullException(nameof(network));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true,
+ LocalNetworkSubnets = network.Split(',')
+ };
+
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>());
+
+ Assert.False(nm.IsInLocalNetwork(value));
+ }
+
+ /// <summary>
+ /// Checks IP address formats.
+ /// </summary>
+ /// <param name="address"></param>
+ [Theory]
+ [InlineData("127.0.0.1")]
+ [InlineData("127.0.0.1:123")]
+ [InlineData("localhost")]
+ [InlineData("localhost:1345")]
+ [InlineData("www.google.co.uk")]
+ [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517")]
+ [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517/56")]
+ [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517]:124")]
+ [InlineData("fe80::7add:12ff:febb:c67b%16")]
+ [InlineData("[fe80::7add:12ff:febb:c67b%16]:123")]
+ [InlineData("192.168.1.2/255.255.255.0")]
+ [InlineData("192.168.1.2/24")]
+ public void ValidIPStrings(string address)
+ {
+ Assert.True(TryParse(address, out _));
+ }
+
+
+ /// <summary>
+ /// All should be invalid address strings.
+ /// </summary>
+ /// <param name="address">Invalid address strings.</param>
+ [Theory]
+ [InlineData("256.128.0.0.0.1")]
+ [InlineData("127.0.0.1#")]
+ [InlineData("localhost!")]
+ [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")]
+ [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")]
+ public void InvalidAddressString(string address)
+ {
+ Assert.False(TryParse(address, out _));
+ }
+
+
+ /// <summary>
+ /// Test collection parsing.
+ /// </summary>
+ /// <param name="settings">Collection to parse.</param>
+ /// <param name="result1">Included addresses from the collection.</param>
+ /// <param name="result2">Included IP4 addresses from the collection.</param>
+ /// <param name="result3">Excluded addresses from the collection.</param>
+ /// <param name="result4">Excluded IP4 addresses from the collection.</param>
+ /// <param name="result5">Network addresses of the collection.</param>
+ [Theory]
+ [InlineData("127.0.0.1#",
+ "[]",
+ "[]",
+ "[]",
+ "[]",
+ "[]")]
+ [InlineData("!127.0.0.1",
+ "[]",
+ "[]",
+ "[127.0.0.1/32]",
+ "[127.0.0.1/32]",
+ "[]")]
+ [InlineData("",
+ "[]",
+ "[]",
+ "[]",
+ "[]",
+ "[]")]
+ [InlineData(
+ "192.158.1.2/16, localhost, fd23:184f:2029:0:3139:7386:67d7:d517, !10.10.10.10",
+ "[192.158.1.2/16,127.0.0.1/32,fd23:184f:2029:0:3139:7386:67d7:d517/128]",
+ "[192.158.1.2/16,127.0.0.1/32]",
+ "[10.10.10.10/32]",
+ "[10.10.10.10/32]",
+ "[192.158.0.0/16,127.0.0.1/32,fd23:184f:2029:0:3139:7386:67d7:d517/128]")]
+ [InlineData("192.158.1.2/255.255.0.0,192.169.1.2/8",
+ "[192.158.1.2/16,192.169.1.2/8]",
+ "[192.158.1.2/16,192.169.1.2/8]",
+ "[]",
+ "[]",
+ "[192.158.0.0/16,192.0.0.0/8]")]
+ public void TestCollections(string settings, string result1, string result2, string result3, string result4, string result5)
+ {
+ if (settings == null)
+ {
+ throw new ArgumentNullException(nameof(settings));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true,
+ };
+
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>());
+
+ // Test included.
+ Collection<IPObject> nc = nm.CreateIPCollection(settings.Split(","), false);
+ Assert.Equal(nc.AsString(), result1);
+
+ // Test excluded.
+ nc = nm.CreateIPCollection(settings.Split(","), true);
+ Assert.Equal(nc.AsString(), result3);
+
+ conf.EnableIPV6 = false;
+ nm.UpdateSettings(conf);
+
+ // Test IP4 included.
+ nc = nm.CreateIPCollection(settings.Split(","), false);
+ Assert.Equal(nc.AsString(), result2);
+
+ // Test IP4 excluded.
+ nc = nm.CreateIPCollection(settings.Split(","), true);
+ Assert.Equal(nc.AsString(), result4);
+
+ conf.EnableIPV6 = true;
+ nm.UpdateSettings(conf);
+
+ // Test network addresses of collection.
+ nc = nm.CreateIPCollection(settings.Split(","), false);
+ nc = nc.AsNetworks();
+ Assert.Equal(nc.AsString(), result5);
+ }
+
+ /// <summary>
+ /// Union two collections.
+ /// </summary>
+ /// <param name="settings">Source.</param>
+ /// <param name="compare">Destination.</param>
+ /// <param name="result">Result.</param>
+ [Theory]
+ [InlineData("127.0.0.1", "fd23:184f:2029:0:3139:7386:67d7:d517/64,fd23:184f:2029:0:c0f0:8a8a:7605:fffa/128,fe80::3139:7386:67d7:d517%16/64,192.168.1.208/24,::1/128,127.0.0.1/8", "[127.0.0.1/32]")]
+ [InlineData("127.0.0.1", "127.0.0.1/8", "[127.0.0.1/32]")]
+ public void UnionCheck(string settings, string compare, string result)
+ {
+ if (settings == null)
+ {
+ throw new ArgumentNullException(nameof(settings));
+ }
+
+ if (compare == null)
+ {
+ throw new ArgumentNullException(nameof(compare));
+ }
+
+ if (result == null)
+ {
+ throw new ArgumentNullException(nameof(result));
+ }
+
+
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true,
+ };
+
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>());
+
+ Collection<IPObject> nc1 = nm.CreateIPCollection(settings.Split(","), false);
+ Collection<IPObject> nc2 = nm.CreateIPCollection(compare.Split(","), false);
+
+ Assert.Equal(nc1.Union(nc2).AsString(), result);
+ }
+
+ [Theory]
+ [InlineData("192.168.5.85/24", "192.168.5.1")]
+ [InlineData("192.168.5.85/24", "192.168.5.254")]
+ [InlineData("10.128.240.50/30", "10.128.240.48")]
+ [InlineData("10.128.240.50/30", "10.128.240.49")]
+ [InlineData("10.128.240.50/30", "10.128.240.50")]
+ [InlineData("10.128.240.50/30", "10.128.240.51")]
+ [InlineData("127.0.0.1/8", "127.0.0.1")]
+ public void IpV4SubnetMaskMatchesValidIpAddress(string netMask, string ipAddress)
+ {
+ var ipAddressObj = IPNetAddress.Parse(netMask);
+ Assert.True(ipAddressObj.Contains(IPAddress.Parse(ipAddress)));
+ }
+
+ [Theory]
+ [InlineData("192.168.5.85/24", "192.168.4.254")]
+ [InlineData("192.168.5.85/24", "191.168.5.254")]
+ [InlineData("10.128.240.50/30", "10.128.240.47")]
+ [InlineData("10.128.240.50/30", "10.128.240.52")]
+ [InlineData("10.128.240.50/30", "10.128.239.50")]
+ [InlineData("10.128.240.50/30", "10.127.240.51")]
+ public void IpV4SubnetMaskDoesNotMatchInvalidIpAddress(string netMask, string ipAddress)
+ {
+ var ipAddressObj = IPNetAddress.Parse(netMask);
+ Assert.False(ipAddressObj.Contains(IPAddress.Parse(ipAddress)));
+ }
+
+ [Theory]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:FFFF:FFFF:FFFF:FFFF")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:0001:0000:0000:0000")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:FFFF:FFFF:FFFF:FFF0")]
+ [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")]
+ public void IpV6SubnetMaskMatchesValidIpAddress(string netMask, string ipAddress)
+ {
+ var ipAddressObj = IPNetAddress.Parse(netMask);
+ Assert.True(ipAddressObj.Contains(IPAddress.Parse(ipAddress)));
+ }
+
+ [Theory]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0011:FFFF:FFFF:FFFF:FFFF")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0013:0000:0000:0000:0000")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0013:0001:0000:0000:0000")]
+ [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0011:FFFF:FFFF:FFFF:FFF0")]
+ [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0001")]
+ public void IpV6SubnetMaskDoesNotMatchInvalidIpAddress(string netMask, string ipAddress)
+ {
+ var ipAddressObj = IPNetAddress.Parse(netMask);
+ Assert.False(ipAddressObj.Contains(IPAddress.Parse(ipAddress)));
+ }
+
+ [Theory]
+ [InlineData("10.0.0.0/255.0.0.0", "10.10.10.1/32")]
+ [InlineData("10.0.0.0/8", "10.10.10.1/32")]
+ [InlineData("10.0.0.0/255.0.0.0", "10.10.10.1")]
+
+ [InlineData("10.10.0.0/255.255.0.0", "10.10.10.1/32")]
+ [InlineData("10.10.0.0/16", "10.10.10.1/32")]
+ [InlineData("10.10.0.0/255.255.0.0", "10.10.10.1")]
+
+ [InlineData("10.10.10.0/255.255.255.0", "10.10.10.1/32")]
+ [InlineData("10.10.10.0/24", "10.10.10.1/32")]
+ [InlineData("10.10.10.0/255.255.255.0", "10.10.10.1")]
+
+ public void TestSubnetContains(string network, string ip)
+ {
+ Assert.True(TryParse(network, out IPObject? networkObj));
+ Assert.True(TryParse(ip, out IPObject? ipObj));
+ Assert.True(networkObj.Contains(ipObj));
+ }
+
+ [Theory]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "172.168.1.2/24", "172.168.1.2/24")]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "172.168.1.2/24, 10.10.10.1", "172.168.1.2/24,10.10.10.1/24")]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "192.168.1.2/255.255.255.0, 10.10.10.1", "192.168.1.2/24,10.10.10.1/24")]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "192.168.1.2/24, 100.10.10.1", "192.168.1.2/24")]
+ [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "194.168.1.2/24, 100.10.10.1", "")]
+
+ public void TestCollectionEquality(string source, string dest, string result)
+ {
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ if (dest == null)
+ {
+ throw new ArgumentNullException(nameof(dest));
+ }
+
+ if (result == null)
+ {
+ throw new ArgumentNullException(nameof(result));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ EnableIPV6 = true,
+ EnableIPV4 = true
+ };
+
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>());
+
+ // Test included, IP6.
+ Collection<IPObject> ncSource = nm.CreateIPCollection(source.Split(","));
+ Collection<IPObject> ncDest = nm.CreateIPCollection(dest.Split(","));
+ Collection<IPObject> ncResult = ncSource.Union(ncDest);
+ Collection<IPObject> resultCollection = nm.CreateIPCollection(result.Split(","));
+ Assert.True(ncResult.Compare(resultCollection));
+ }
+
+
+ [Theory]
+ [InlineData("10.1.1.1/32", "10.1.1.1")]
+ [InlineData("192.168.1.254/32", "192.168.1.254/255.255.255.255")]
+
+ public void TestEquals(string source, string dest)
+ {
+ Assert.True(IPNetAddress.Parse(source).Equals(IPNetAddress.Parse(dest)));
+ Assert.True(IPNetAddress.Parse(dest).Equals(IPNetAddress.Parse(source)));
+ }
+
+ [Theory]
+
+ // Testing bind interfaces.
+ // On my system eth16 is internal, eth11 external (Windows defines the indexes).
+ //
+ // This test is to replicate how DNLA requests work throughout the system.
+
+ // User on internal network, we're bound internal and external - so result is internal.
+ [InlineData("192.168.1.1", "eth16,eth11", false, "eth16")]
+ // User on external network, we're bound internal and external - so result is external.
+ [InlineData("8.8.8.8", "eth16,eth11", false, "eth11")]
+ // User on internal network, we're bound internal only - so result is internal.
+ [InlineData("10.10.10.10", "eth16", false, "eth16")]
+ // User on internal network, no binding specified - so result is the 1st internal.
+ [InlineData("192.168.1.1", "", false, "eth16")]
+ // User on external network, internal binding only - so result is the 1st internal.
+ [InlineData("jellyfin.org", "eth16", false, "eth16")]
+ // User on external network, no binding - so result is the 1st external.
+ [InlineData("jellyfin.org", "", false, "eth11")]
+ // User assumed to be internal, no binding - so result is the 1st internal.
+ [InlineData("", "", false, "eth16")]
+ public void TestBindInterfaces(string source, string bindAddresses, bool ipv6enabled, string result)
+ {
+ if (source == null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ if (bindAddresses == null)
+ {
+ throw new ArgumentNullException(nameof(bindAddresses));
+ }
+
+ if (result == null)
+ {
+ throw new ArgumentNullException(nameof(result));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ LocalNetworkAddresses = bindAddresses.Split(','),
+ EnableIPV6 = ipv6enabled,
+ EnableIPV4 = true
+ };
+
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16:200.200.200.200/24,11,eth11";
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ _ = nm.TryParseInterface(result, out Collection<IPObject>? resultObj);
+
+ if (resultObj != null)
+ {
+ result = ((IPNetAddress)resultObj[0]).ToString(true);
+ var intf = nm.GetBindInterface(source, out int? _);
+
+ Assert.Equal(intf, result);
+ }
+ }
+
+ [Theory]
+
+ // Testing bind interfaces. These are set for my system so won't work elsewhere.
+ // On my system eth16 is internal, eth11 external (Windows defines the indexes).
+ //
+ // This test is to replicate how subnet bound ServerPublisherUri work throughout the system.
+
+ // User on internal network, we're bound internal and external - so result is internal override.
+ [InlineData("192.168.1.1", "192.168.1.0/24", "eth16,eth11", false, "192.168.1.0/24=internal.jellyfin", "internal.jellyfin")]
+
+ // User on external network, we're bound internal and external - so result is override.
+ [InlineData("8.8.8.8", "192.168.1.0/24", "eth16,eth11", false, "0.0.0.0=http://helloworld.com", "http://helloworld.com")]
+
+ // User on internal network, we're bound internal only, but the address isn't in the LAN - so return the override.
+ [InlineData("10.10.10.10", "192.168.1.0/24", "eth16", false, "0.0.0.0=http://internalButNotDefinedAsLan.com", "http://internalButNotDefinedAsLan.com")]
+
+ // User on internal network, no binding specified - so result is the 1st internal.
+ [InlineData("192.168.1.1", "192.168.1.0/24", "", false, "0.0.0.0=http://helloworld.com", "eth16")]
+
+ // User on external network, internal binding only - so asumption is a proxy forward, return external override.
+ [InlineData("jellyfin.org", "192.168.1.0/24", "eth16", false, "0.0.0.0=http://helloworld.com", "http://helloworld.com")]
+
+ // User on external network, no binding - so result is the 1st external which is overriden.
+ [InlineData("jellyfin.org", "192.168.1.0/24", "", false, "0.0.0.0 = http://helloworld.com", "http://helloworld.com")]
+
+ // User assumed to be internal, no binding - so result is the 1st internal.
+ [InlineData("", "192.168.1.0/24", "", false, "0.0.0.0=http://helloworld.com", "eth16")]
+
+ // User is internal, no binding - so result is the 1st internal, which is then overridden.
+ [InlineData("192.168.1.1", "192.168.1.0/24", "", false, "eth16=http://helloworld.com", "http://helloworld.com")]
+
+ public void TestBindInterfaceOverrides(string source, string lan, string bindAddresses, bool ipv6enabled, string publishedServers, string result)
+ {
+ if (lan == null)
+ {
+ throw new ArgumentNullException(nameof(lan));
+ }
+
+ if (bindAddresses == null)
+ {
+ throw new ArgumentNullException(nameof(bindAddresses));
+ }
+
+ var conf = new NetworkConfiguration()
+ {
+ LocalNetworkSubnets = lan.Split(','),
+ LocalNetworkAddresses = bindAddresses.Split(','),
+ EnableIPV6 = ipv6enabled,
+ EnableIPV4 = true,
+ PublishedServerUriBySubnet = new string[] { publishedServers }
+ };
+
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16:200.200.200.200/24,11,eth11";
+ using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ if (nm.TryParseInterface(result, out Collection<IPObject>? resultObj) && resultObj != null)
+ {
+ // Parse out IPAddresses so we can do a string comparison. (Ignore subnet masks).
+ result = ((IPNetAddress)resultObj[0]).ToString(true);
+ }
+
+ var intf = nm.GetBindInterface(source, out int? _);
+
+ Assert.Equal(intf, result);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
index 547f80ed9..fffbc6212 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
+++ b/tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj
@@ -17,7 +17,7 @@
<PackageReference Include="AutoFixture" Version="4.14.0" />
<PackageReference Include="AutoFixture.AutoMoq" Version="4.14.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.0" />
- <PackageReference Include="Moq" Version="4.15.1" />
+ <PackageReference Include="Moq" Version="4.15.2" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" />
<PackageReference Include="coverlet.collector" Version="1.3.0" />