diff --git a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs index 58421aaf19..18bea59ad5 100644 --- a/Emby.Server.Implementations/HttpServer/Security/AuthService.cs +++ b/Emby.Server.Implementations/HttpServer/Security/AuthService.cs @@ -146,11 +146,17 @@ namespace Emby.Server.Implementations.HttpServer.Security { return true; } + if (authAttribtues.AllowLocalOnly && request.IsLocal) { return true; } + if (authAttribtues.IgnoreLegacyAuth) + { + return true; + } + return false; } diff --git a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs index 26f7d9d2dd..a0c9c3f5aa 100644 --- a/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs +++ b/Jellyfin.Api/Auth/CustomAuthenticationHandler.cs @@ -37,13 +37,20 @@ namespace Jellyfin.Api.Auth /// protected override Task HandleAuthenticateAsync() { - var authenticatedAttribute = new AuthenticatedAttribute(); + var authenticatedAttribute = new AuthenticatedAttribute + { + IgnoreLegacyAuth = true + }; + try { var user = _authService.Authenticate(Request, authenticatedAttribute); if (user == null) { - return Task.FromResult(AuthenticateResult.Fail("Invalid user")); + return Task.FromResult(AuthenticateResult.NoResult()); + // TODO return when legacy API is removed. + // Don't spam the log with "Invalid User" + // return Task.FromResult(AuthenticateResult.Fail("Invalid user")); } var claims = new[] diff --git a/Jellyfin.Api/BaseJellyfinApiController.cs b/Jellyfin.Api/BaseJellyfinApiController.cs index 1f4508e6cb..22b9c3fd67 100644 --- a/Jellyfin.Api/BaseJellyfinApiController.cs +++ b/Jellyfin.Api/BaseJellyfinApiController.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.AspNetCore.Mvc; namespace Jellyfin.Api diff --git a/Jellyfin.Api/Controllers/ActivityLogController.cs b/Jellyfin.Api/Controllers/ActivityLogController.cs new file mode 100644 index 0000000000..8d37a83738 --- /dev/null +++ b/Jellyfin.Api/Controllers/ActivityLogController.cs @@ -0,0 +1,50 @@ +using System; +using System.Globalization; +using Jellyfin.Api.Constants; +using MediaBrowser.Model.Activity; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// + /// Activity log controller. + /// + [Route("/System/ActivityLog")] + [Authorize(Policy = Policies.RequiresElevation)] + public class ActivityLogController : BaseJellyfinApiController + { + private readonly IActivityManager _activityManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of interface. + public ActivityLogController(IActivityManager activityManager) + { + _activityManager = activityManager; + } + + /// + /// Gets activity log entries. + /// + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. The minimum date. Format = ISO. + /// Optional. Only returns activities that have a user associated. + /// Activity log returned. + /// A containing the log entries. + [HttpGet("Entries")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetLogEntries( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] DateTime? minDate, + bool? hasUserId) + { + return _activityManager.GetActivityLogEntries(minDate, hasUserId, startIndex, limit); + } + } +} diff --git a/Jellyfin.Api/Controllers/ApiKeyController.cs b/Jellyfin.Api/Controllers/ApiKeyController.cs new file mode 100644 index 0000000000..ed521c1fc5 --- /dev/null +++ b/Jellyfin.Api/Controllers/ApiKeyController.cs @@ -0,0 +1,97 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// + /// Authentication controller. + /// + [Route("/Auth")] + public class ApiKeyController : BaseJellyfinApiController + { + private readonly ISessionManager _sessionManager; + private readonly IServerApplicationHost _appHost; + private readonly IAuthenticationRepository _authRepo; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + public ApiKeyController( + ISessionManager sessionManager, + IServerApplicationHost appHost, + IAuthenticationRepository authRepo) + { + _sessionManager = sessionManager; + _appHost = appHost; + _authRepo = authRepo; + } + + /// + /// Get all keys. + /// + /// Api keys retrieved. + /// A with all keys. + [HttpGet("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetKeys() + { + var result = _authRepo.Get(new AuthenticationInfoQuery + { + HasUser = false + }); + + return result; + } + + /// + /// Create a new api key. + /// + /// Name of the app using the authentication key. + /// Api key created. + /// A . + [HttpPost("Keys")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult CreateKey([FromQuery, Required] string app) + { + _authRepo.Create(new AuthenticationInfo + { + AppName = app, + AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), + DateCreated = DateTime.UtcNow, + DeviceId = _appHost.SystemId, + DeviceName = _appHost.FriendlyName, + AppVersion = _appHost.ApplicationVersionString + }); + return NoContent(); + } + + /// + /// Remove an api key. + /// + /// The access token to delete. + /// Api key deleted. + /// A . + [HttpDelete("Keys/{key}")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult RevokeKey([FromRoute] string key) + { + _sessionManager.RevokeToken(key); + return NoContent(); + } + } +} diff --git a/Jellyfin.Api/Controllers/ConfigurationController.cs b/Jellyfin.Api/Controllers/ConfigurationController.cs index 992cb00874..2a1dce74d4 100644 --- a/Jellyfin.Api/Controllers/ConfigurationController.cs +++ b/Jellyfin.Api/Controllers/ConfigurationController.cs @@ -1,12 +1,12 @@ #nullable enable +using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Api.Constants; using Jellyfin.Api.Models.ConfigurationDtos; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Serialization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -23,22 +23,18 @@ namespace Jellyfin.Api.Controllers { private readonly IServerConfigurationManager _configurationManager; private readonly IMediaEncoder _mediaEncoder; - private readonly IJsonSerializer _jsonSerializer; /// /// Initializes a new instance of the class. /// /// Instance of the interface. /// Instance of the interface. - /// Instance of the interface. public ConfigurationController( IServerConfigurationManager configurationManager, - IMediaEncoder mediaEncoder, - IJsonSerializer jsonSerializer) + IMediaEncoder mediaEncoder) { _configurationManager = configurationManager; _mediaEncoder = mediaEncoder; - _jsonSerializer = jsonSerializer; } /// @@ -93,13 +89,7 @@ namespace Jellyfin.Api.Controllers public async Task UpdateNamedConfiguration([FromRoute] string key) { var configurationType = _configurationManager.GetConfigurationType(key); - /* - // TODO switch to System.Text.Json when https://github.com/dotnet/runtime/issues/30255 is fixed. - var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType); - */ - - var configuration = await _jsonSerializer.DeserializeFromStreamAsync(Request.Body, configurationType) - .ConfigureAwait(false); + var configuration = await JsonSerializer.DeserializeAsync(Request.Body, configurationType).ConfigureAwait(false); _configurationManager.SaveConfiguration(key, configuration); return Ok(); } diff --git a/Jellyfin.Api/Controllers/DevicesController.cs b/Jellyfin.Api/Controllers/DevicesController.cs new file mode 100644 index 0000000000..1e75579033 --- /dev/null +++ b/Jellyfin.Api/Controllers/DevicesController.cs @@ -0,0 +1,156 @@ +#nullable enable + +using System; +using Jellyfin.Api.Constants; +using MediaBrowser.Controller.Devices; +using MediaBrowser.Controller.Security; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Devices; +using MediaBrowser.Model.Querying; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers +{ + /// + /// Devices Controller. + /// + [Authorize] + public class DevicesController : BaseJellyfinApiController + { + private readonly IDeviceManager _deviceManager; + private readonly IAuthenticationRepository _authenticationRepository; + private readonly ISessionManager _sessionManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + public DevicesController( + IDeviceManager deviceManager, + IAuthenticationRepository authenticationRepository, + ISessionManager sessionManager) + { + _deviceManager = deviceManager; + _authenticationRepository = authenticationRepository; + _sessionManager = sessionManager; + } + + /// + /// Get Devices. + /// + /// Gets or sets a value indicating whether [supports synchronize]. + /// Gets or sets the user identifier. + /// Devices retrieved. + /// An containing the list of devices. + [HttpGet] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId) + { + var deviceQuery = new DeviceQuery { SupportsSync = supportsSync, UserId = userId ?? Guid.Empty }; + return _deviceManager.GetDevices(deviceQuery); + } + + /// + /// Get info for a device. + /// + /// Device Id. + /// Device info retrieved. + /// Device not found. + /// An containing the device info on success, or a if the device could not be found. + [HttpGet("Info")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetDeviceInfo([FromQuery, BindRequired] string id) + { + var deviceInfo = _deviceManager.GetDevice(id); + if (deviceInfo == null) + { + return NotFound(); + } + + return deviceInfo; + } + + /// + /// Get options for a device. + /// + /// Device Id. + /// Device options retrieved. + /// Device not found. + /// An containing the device info on success, or a if the device could not be found. + [HttpGet("Options")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult GetDeviceOptions([FromQuery, BindRequired] string id) + { + var deviceInfo = _deviceManager.GetDeviceOptions(id); + if (deviceInfo == null) + { + return NotFound(); + } + + return deviceInfo; + } + + /// + /// Update device options. + /// + /// Device Id. + /// Device Options. + /// Device options updated. + /// Device not found. + /// An on success, or a if the device could not be found. + [HttpPost("Options")] + [Authorize(Policy = Policies.RequiresElevation)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public ActionResult UpdateDeviceOptions( + [FromQuery, BindRequired] string id, + [FromBody, BindRequired] DeviceOptions deviceOptions) + { + var existingDeviceOptions = _deviceManager.GetDeviceOptions(id); + if (existingDeviceOptions == null) + { + return NotFound(); + } + + _deviceManager.UpdateDeviceOptions(id, deviceOptions); + return Ok(); + } + + /// + /// Deletes a device. + /// + /// Device Id. + /// Device deleted. + /// Device not found. + /// An on success, or a if the device could not be found. + [HttpDelete] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult DeleteDevice([FromQuery, BindRequired] string id) + { + var existingDevice = _deviceManager.GetDevice(id); + if (existingDevice == null) + { + return NotFound(); + } + + var sessions = _authenticationRepository.Get(new AuthenticationInfoQuery { DeviceId = id }).Items; + + foreach (var session in sessions) + { + _sessionManager.Logout(session); + } + + return Ok(); + } + } +} diff --git a/Jellyfin.Api/Controllers/PackageController.cs b/Jellyfin.Api/Controllers/PackageController.cs new file mode 100644 index 0000000000..f37319c19e --- /dev/null +++ b/Jellyfin.Api/Controllers/PackageController.cs @@ -0,0 +1,120 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Jellyfin.Api.Constants; +using MediaBrowser.Common.Updates; +using MediaBrowser.Model.Updates; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// + /// Package Controller. + /// + [Route("Packages")] + [Authorize] + public class PackageController : BaseJellyfinApiController + { + private readonly IInstallationManager _installationManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of Installation Manager. + public PackageController(IInstallationManager installationManager) + { + _installationManager = installationManager; + } + + /// + /// Gets a package by name or assembly GUID. + /// + /// The name of the package. + /// The GUID of the associated assembly. + /// A containing package information. + [HttpGet("/{Name}")] + [ProducesResponseType(typeof(PackageInfo), StatusCodes.Status200OK)] + public async Task> GetPackageInfo( + [FromRoute] [Required] string name, + [FromQuery] string? assemblyGuid) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + var result = _installationManager.FilterPackages( + packages, + name, + string.IsNullOrEmpty(assemblyGuid) ? default : Guid.Parse(assemblyGuid)).FirstOrDefault(); + + return result; + } + + /// + /// Gets available packages. + /// + /// An containing available packages information. + [HttpGet] + [ProducesResponseType(typeof(PackageInfo[]), StatusCodes.Status200OK)] + public async Task> GetPackages() + { + IEnumerable packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + + return packages; + } + + /// + /// Installs a package. + /// + /// Package name. + /// GUID of the associated assembly. + /// Optional version. Defaults to latest version. + /// Package found. + /// Package not found. + /// An on success, or a if the package could not be found. + [HttpPost("/Installed/{Name}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Authorize(Policy = Policies.RequiresElevation)] + public async Task InstallPackage( + [FromRoute] [Required] string name, + [FromQuery] string assemblyGuid, + [FromQuery] string version) + { + var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); + var package = _installationManager.GetCompatibleVersions( + packages, + name, + string.IsNullOrEmpty(assemblyGuid) ? Guid.Empty : Guid.Parse(assemblyGuid), + string.IsNullOrEmpty(version) ? null : Version.Parse(version)).FirstOrDefault(); + + if (package == null) + { + return NotFound(); + } + + await _installationManager.InstallPackage(package).ConfigureAwait(false); + + return Ok(); + } + + /// + /// Cancels a package installation. + /// + /// Installation Id. + /// Installation cancelled. + /// An on successfully cancelling a package installation. + [HttpDelete("/Installing/{id}")] + [Authorize(Policy = Policies.RequiresElevation)] + public IActionResult CancelPackageInstallation( + [FromRoute] [Required] string id) + { + _installationManager.CancelInstallation(new Guid(id)); + + return Ok(); + } + } +} diff --git a/Jellyfin.Api/Controllers/SearchController.cs b/Jellyfin.Api/Controllers/SearchController.cs new file mode 100644 index 0000000000..ec05e4fb4f --- /dev/null +++ b/Jellyfin.Api/Controllers/SearchController.cs @@ -0,0 +1,268 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Linq; +using Jellyfin.Api.Helpers; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Dto; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Audio; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Search; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// + /// Search controller. + /// + [Route("/Search/Hints")] + [Authorize] + public class SearchController : BaseJellyfinApiController + { + private readonly ISearchEngine _searchEngine; + private readonly ILibraryManager _libraryManager; + private readonly IDtoService _dtoService; + private readonly IImageProcessor _imageProcessor; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + /// Instance of interface. + public SearchController( + ISearchEngine searchEngine, + ILibraryManager libraryManager, + IDtoService dtoService, + IImageProcessor imageProcessor) + { + _searchEngine = searchEngine; + _libraryManager = libraryManager; + _dtoService = dtoService; + _imageProcessor = imageProcessor; + } + + /// + /// Gets the search hint result. + /// + /// Optional. The record index to start at. All items with a lower index will be dropped from the results. + /// Optional. The maximum number of records to return. + /// Optional. Supply a user id to search within a user's library or omit to search all. + /// The search term to filter on. + /// If specified, only results with the specified item types are returned. This allows multiple, comma delimeted. + /// If specified, results with these item types are filtered out. This allows multiple, comma delimeted. + /// If specified, only results with the specified media types are returned. This allows multiple, comma delimeted. + /// If specified, only children of the parent are returned. + /// Optional filter for movies. + /// Optional filter for series. + /// Optional filter for news. + /// Optional filter for kids. + /// Optional filter for sports. + /// Optional filter whether to include people. + /// Optional filter whether to include media. + /// Optional filter whether to include genres. + /// Optional filter whether to include studios. + /// Optional filter whether to include artists. + /// Search hint returned. + /// An with the results of the search. + [HttpGet] + [Description("Gets search hints based on a search term")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult Get( + [FromQuery] int? startIndex, + [FromQuery] int? limit, + [FromQuery] Guid userId, + [FromQuery, Required] string searchTerm, + [FromQuery] string includeItemTypes, + [FromQuery] string excludeItemTypes, + [FromQuery] string mediaTypes, + [FromQuery] string parentId, + [FromQuery] bool? isMovie, + [FromQuery] bool? isSeries, + [FromQuery] bool? isNews, + [FromQuery] bool? isKids, + [FromQuery] bool? isSports, + [FromQuery] bool includePeople = true, + [FromQuery] bool includeMedia = true, + [FromQuery] bool includeGenres = true, + [FromQuery] bool includeStudios = true, + [FromQuery] bool includeArtists = true) + { + var result = _searchEngine.GetSearchHints(new SearchQuery + { + Limit = limit, + SearchTerm = searchTerm, + IncludeArtists = includeArtists, + IncludeGenres = includeGenres, + IncludeMedia = includeMedia, + IncludePeople = includePeople, + IncludeStudios = includeStudios, + StartIndex = startIndex, + UserId = userId, + IncludeItemTypes = RequestHelpers.Split(includeItemTypes, ',', true), + ExcludeItemTypes = RequestHelpers.Split(excludeItemTypes, ',', true), + MediaTypes = RequestHelpers.Split(mediaTypes, ',', true), + ParentId = parentId, + + IsKids = isKids, + IsMovie = isMovie, + IsNews = isNews, + IsSeries = isSeries, + IsSports = isSports + }); + + return new SearchHintResult + { + TotalRecordCount = result.TotalRecordCount, + SearchHints = result.Items.Select(GetSearchHintResult).ToArray() + }; + } + + /// + /// Gets the search hint result. + /// + /// The hint info. + /// SearchHintResult. + private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) + { + var item = hintInfo.Item; + + var result = new SearchHint + { + Name = item.Name, + IndexNumber = item.IndexNumber, + ParentIndexNumber = item.ParentIndexNumber, + Id = item.Id, + Type = item.GetClientTypeName(), + MediaType = item.MediaType, + MatchedTerm = hintInfo.MatchedTerm, + RunTimeTicks = item.RunTimeTicks, + ProductionYear = item.ProductionYear, + ChannelId = item.ChannelId, + EndDate = item.EndDate + }; + + // legacy + result.ItemId = result.Id; + + if (item.IsFolder) + { + result.IsFolder = true; + } + + var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); + + if (primaryImageTag != null) + { + result.PrimaryImageTag = primaryImageTag; + result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); + } + + SetThumbImageInfo(result, item); + SetBackdropImageInfo(result, item); + + switch (item) + { + case IHasSeries hasSeries: + result.Series = hasSeries.SeriesName; + break; + case LiveTvProgram program: + result.StartDate = program.StartDate; + break; + case Series series: + if (series.Status.HasValue) + { + result.Status = series.Status.Value.ToString(); + } + + break; + case MusicAlbum album: + result.Artists = album.Artists; + result.AlbumArtist = album.AlbumArtist; + break; + case Audio song: + result.AlbumArtist = song.AlbumArtists?[0]; + result.Artists = song.Artists; + + MusicAlbum musicAlbum = song.AlbumEntity; + + if (musicAlbum != null) + { + result.Album = musicAlbum.Name; + result.AlbumId = musicAlbum.Id; + } + else + { + result.Album = song.Album; + } + + break; + } + + if (!item.ChannelId.Equals(Guid.Empty)) + { + var channel = _libraryManager.GetItemById(item.ChannelId); + result.ChannelName = channel?.Name; + } + + return result; + } + + private void SetThumbImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; + + if (itemWithImage == null && item is Episode) + { + itemWithImage = GetParentWithImage(item, ImageType.Thumb); + } + + if (itemWithImage == null) + { + itemWithImage = GetParentWithImage(item, ImageType.Thumb); + } + + if (itemWithImage != null) + { + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); + + if (tag != null) + { + hint.ThumbImageTag = tag; + hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); + } + } + } + + private void SetBackdropImageInfo(SearchHint hint, BaseItem item) + { + var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) + ?? GetParentWithImage(item, ImageType.Backdrop); + + if (itemWithImage != null) + { + var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); + + if (tag != null) + { + hint.BackdropImageTag = tag; + hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); + } + } + } + + private T GetParentWithImage(BaseItem item, ImageType type) + where T : BaseItem + { + return item.GetParents().OfType().FirstOrDefault(i => i.HasImage(type)); + } + } +} diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs index ed1dc1ede3..57a02e62a9 100644 --- a/Jellyfin.Api/Controllers/StartupController.cs +++ b/Jellyfin.Api/Controllers/StartupController.cs @@ -109,6 +109,7 @@ namespace Jellyfin.Api.Controllers /// Initial user retrieved. /// The first user. [HttpGet("User")] + [HttpGet("FirstUser")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult GetFirstUser() { diff --git a/Jellyfin.Api/Controllers/VideoAttachmentsController.cs b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs new file mode 100644 index 0000000000..86d9322fe4 --- /dev/null +++ b/Jellyfin.Api/Controllers/VideoAttachmentsController.cs @@ -0,0 +1,84 @@ +#nullable enable + +using System; +using System.Net.Mime; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Common.Extensions; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.MediaEncoding; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Api.Controllers +{ + /// + /// Attachments controller. + /// + [Route("Videos")] + [Authorize] + public class VideoAttachmentsController : BaseJellyfinApiController + { + private readonly ILibraryManager _libraryManager; + private readonly IAttachmentExtractor _attachmentExtractor; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public VideoAttachmentsController( + ILibraryManager libraryManager, + IAttachmentExtractor attachmentExtractor) + { + _libraryManager = libraryManager; + _attachmentExtractor = attachmentExtractor; + } + + /// + /// Get video attachment. + /// + /// Video ID. + /// Media Source ID. + /// Attachment Index. + /// Attachment retrieved. + /// Video or attachment not found. + /// An containing the attachment stream on success, or a if the attachment could not be found. + [HttpGet("{VideoID}/{MediaSourceID}/Attachments/{Index}")] + [Produces(MediaTypeNames.Application.Octet)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetAttachment( + [FromRoute] Guid videoId, + [FromRoute] string mediaSourceId, + [FromRoute] int index) + { + try + { + var item = _libraryManager.GetItemById(videoId); + if (item == null) + { + return NotFound(); + } + + var (attachment, stream) = await _attachmentExtractor.GetAttachment( + item, + mediaSourceId, + index, + CancellationToken.None) + .ConfigureAwait(false); + + var contentType = string.IsNullOrWhiteSpace(attachment.MimeType) + ? MediaTypeNames.Application.Octet + : attachment.MimeType; + + return new FileStreamResult(stream, contentType); + } + catch (ResourceNotFoundException e) + { + return NotFound(e.Message); + } + } + } +} diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs new file mode 100644 index 0000000000..9f4d34f9c6 --- /dev/null +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -0,0 +1,29 @@ +using System; + +namespace Jellyfin.Api.Helpers +{ + /// + /// Request Extensions. + /// + public static class RequestHelpers + { + /// + /// Splits a string at a separating character into an array of substrings. + /// + /// The string to split. + /// The char that separates the substrings. + /// Option to remove empty substrings from the array. + /// An array of the substrings. + internal static string[] Split(string value, char separator, bool removeEmpty) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Array.Empty(); + } + + return removeEmpty + ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries) + : value.Split(separator); + } + } +} diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index a6817421a8..0097462430 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; -using System.Text.Json.Serialization; using Jellyfin.Api; using Jellyfin.Api.Auth; using Jellyfin.Api.Auth.FirstTimeSetupOrElevatedPolicy; @@ -11,6 +10,9 @@ using Jellyfin.Api.Auth.RequiresElevationPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Server.Formatters; +using Jellyfin.Server.Models; +using MediaBrowser.Common.Json; +using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; @@ -71,11 +73,18 @@ namespace Jellyfin.Server.Extensions /// The MVC builder. public static IMvcBuilder AddJellyfinApi(this IServiceCollection serviceCollection, string baseUrl) { - return serviceCollection.AddMvc(opts => + return serviceCollection + .AddCors(options => + { + options.AddPolicy(ServerCorsPolicy.DefaultPolicyName, ServerCorsPolicy.DefaultPolicy); + }) + .AddMvc(opts => { opts.UseGeneralRoutePrefix(baseUrl); opts.OutputFormatters.Insert(0, new CamelCaseJsonProfileFormatter()); opts.OutputFormatters.Insert(0, new PascalCaseJsonProfileFormatter()); + + opts.OutputFormatters.Add(new CssOutputFormatter()); }) // Clear app parts to avoid other assemblies being picked up @@ -83,8 +92,20 @@ namespace Jellyfin.Server.Extensions .AddApplicationPart(typeof(StartupController).Assembly) .AddJsonOptions(options => { - // Setting the naming policy to null leaves the property names as-is when serializing objects to JSON. - options.JsonSerializerOptions.PropertyNamingPolicy = null; + // Update all properties that are set in JsonDefaults + var jsonOptions = JsonDefaults.PascalCase; + + // From JsonDefaults + options.JsonSerializerOptions.ReadCommentHandling = jsonOptions.ReadCommentHandling; + options.JsonSerializerOptions.WriteIndented = jsonOptions.WriteIndented; + options.JsonSerializerOptions.Converters.Clear(); + foreach (var converter in jsonOptions.Converters) + { + options.JsonSerializerOptions.Converters.Add(converter); + } + + // From JsonDefaults.PascalCase + options.JsonSerializerOptions.PropertyNamingPolicy = jsonOptions.PropertyNamingPolicy; }) .AddControllersAsServices(); } @@ -118,6 +139,7 @@ namespace Jellyfin.Server.Extensions { { securitySchemeRef, Array.Empty() } }); + c.SwaggerDoc("api-docs", new OpenApiInfo { Title = "Jellyfin API", Version = "v1" }); // Add all xml doc files to swagger generator. var xmlFiles = Directory.GetFiles( @@ -137,7 +159,30 @@ namespace Jellyfin.Server.Extensions // Use method name as operationId c.CustomOperationIds(description => description.TryGetMethodInfo(out MethodInfo methodInfo) ? methodInfo.Name : null); + + // TODO - remove when all types are supported in System.Text.Json + c.AddSwaggerTypeMappings(); }); } + + private static void AddSwaggerTypeMappings(this SwaggerGenOptions options) + { + /* + * TODO remove when System.Text.Json supports non-string keys. + * Used in Jellyfin.Api.Controller.GetChannels. + */ + options.MapType>(() => + new OpenApiSchema + { + Type = "object", + Properties = typeof(ImageType).GetEnumNames().ToDictionary( + name => name, + name => new OpenApiSchema + { + Type = "string", + Format = "string" + }) + }); + } } } diff --git a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs index e6ad6dfb13..989c8ecea2 100644 --- a/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs @@ -1,4 +1,4 @@ -using Jellyfin.Server.Models; +using MediaBrowser.Common.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; @@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters /// /// Initializes a new instance of the class. /// - public CamelCaseJsonProfileFormatter() : base(JsonOptions.CamelCase) + public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCase) { SupportedMediaTypes.Clear(); SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\"")); diff --git a/Jellyfin.Server/Formatters/CssOutputFormatter.cs b/Jellyfin.Server/Formatters/CssOutputFormatter.cs new file mode 100644 index 0000000000..b3771b7fe6 --- /dev/null +++ b/Jellyfin.Server/Formatters/CssOutputFormatter.cs @@ -0,0 +1,36 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Jellyfin.Server.Formatters +{ + /// + /// Css output formatter. + /// + public class CssOutputFormatter : TextOutputFormatter + { + /// + /// Initializes a new instance of the class. + /// + public CssOutputFormatter() + { + SupportedMediaTypes.Add("text/css"); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + /// + /// Write context object to stream. + /// + /// Writer context. + /// Unused. Writer encoding. + /// Write stream task. + public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding) + { + return context.HttpContext.Response.WriteAsync(context.Object?.ToString()); + } + } +} diff --git a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs index 675f4c79ee..69963b3fb3 100644 --- a/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs +++ b/Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs @@ -1,4 +1,4 @@ -using Jellyfin.Server.Models; +using MediaBrowser.Common.Json; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Net.Http.Headers; @@ -12,7 +12,7 @@ namespace Jellyfin.Server.Formatters /// /// Initializes a new instance of the class. /// - public PascalCaseJsonProfileFormatter() : base(JsonOptions.PascalCase) + public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCase) { SupportedMediaTypes.Clear(); // Add application/json for default formatter diff --git a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs index 0d79bbfaff..63effafc19 100644 --- a/Jellyfin.Server/Middleware/ExceptionMiddleware.cs +++ b/Jellyfin.Server/Middleware/ExceptionMiddleware.cs @@ -7,7 +7,9 @@ using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Net; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Jellyfin.Server.Middleware @@ -20,6 +22,7 @@ namespace Jellyfin.Server.Middleware private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IServerConfigurationManager _configuration; + private readonly IWebHostEnvironment _hostEnvironment; /// /// Initializes a new instance of the class. @@ -27,14 +30,17 @@ namespace Jellyfin.Server.Middleware /// Next request delegate. /// Instance of the interface. /// Instance of the interface. + /// Instance of the interface. public ExceptionMiddleware( RequestDelegate next, ILogger logger, - IServerConfigurationManager serverConfigurationManager) + IServerConfigurationManager serverConfigurationManager, + IWebHostEnvironment hostEnvironment) { _next = next; _logger = logger; _configuration = serverConfigurationManager; + _hostEnvironment = hostEnvironment; } /// @@ -85,7 +91,11 @@ namespace Jellyfin.Server.Middleware context.Response.StatusCode = GetStatusCode(ex); context.Response.ContentType = MediaTypeNames.Text.Plain; - var errorContent = NormalizeExceptionMessage(ex.Message); + + // Don't send exception unless the server is in a Development environment + var errorContent = _hostEnvironment.IsDevelopment() + ? NormalizeExceptionMessage(ex.Message) + : "Error processing request."; await context.Response.WriteAsync(errorContent).ConfigureAwait(false); } } diff --git a/Jellyfin.Server/Models/JsonOptions.cs b/Jellyfin.Server/Models/JsonOptions.cs deleted file mode 100644 index 2f0df3d2c7..0000000000 --- a/Jellyfin.Server/Models/JsonOptions.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Text.Json; - -namespace Jellyfin.Server.Models -{ - /// - /// Json Options. - /// - public static class JsonOptions - { - /// - /// Gets CamelCase json options. - /// - public static JsonSerializerOptions CamelCase - { - get - { - var options = DefaultJsonOptions; - options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - return options; - } - } - - /// - /// Gets PascalCase json options. - /// - public static JsonSerializerOptions PascalCase - { - get - { - var options = DefaultJsonOptions; - options.PropertyNamingPolicy = null; - return options; - } - } - - /// - /// Gets base Json Serializer Options. - /// - private static JsonSerializerOptions DefaultJsonOptions => new JsonSerializerOptions(); - } -} diff --git a/Jellyfin.Server/Models/ServerCorsPolicy.cs b/Jellyfin.Server/Models/ServerCorsPolicy.cs new file mode 100644 index 0000000000..ae010c042e --- /dev/null +++ b/Jellyfin.Server/Models/ServerCorsPolicy.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Cors.Infrastructure; + +namespace Jellyfin.Server.Models +{ + /// + /// Server Cors Policy. + /// + public static class ServerCorsPolicy + { + /// + /// Default policy name. + /// + public const string DefaultPolicyName = "DefaultCorsPolicy"; + + /// + /// Default Policy. Allow Everything. + /// + public static readonly CorsPolicy DefaultPolicy = new CorsPolicy + { + // Allow any origin + Origins = { "*" }, + + // Allow any method + Methods = { "*" }, + + // Allow any header + Headers = { "*" } + }; + } +} \ No newline at end of file diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 7c49afbfc6..bd2887e4a0 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,5 +1,6 @@ using Jellyfin.Server.Extensions; using Jellyfin.Server.Middleware; +using Jellyfin.Server.Models; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using Microsoft.AspNetCore.Builder; @@ -68,9 +69,10 @@ namespace Jellyfin.Server // TODO app.UseMiddleware(); app.Use(serverApplicationHost.ExecuteWebsocketHandlerAsync); - // TODO use when old API is removed: app.UseAuthentication(); + app.UseAuthentication(); app.UseJellyfinApiSwagger(_serverConfigurationManager); app.UseRouting(); + app.UseCors(ServerCorsPolicy.DefaultPolicyName); app.UseAuthorization(); app.UseEndpoints(endpoints => { diff --git a/MediaBrowser.Api/Attachments/AttachmentService.cs b/MediaBrowser.Api/Attachments/AttachmentService.cs deleted file mode 100644 index 1632ca1b06..0000000000 --- a/MediaBrowser.Api/Attachments/AttachmentService.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Attachments -{ - [Route("/Videos/{Id}/{MediaSourceId}/Attachments/{Index}", "GET", Summary = "Gets specified attachment.")] - public class GetAttachment - { - [ApiMember(Name = "Id", Description = "Item Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public Guid Id { get; set; } - - [ApiMember(Name = "MediaSourceId", Description = "MediaSourceId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string MediaSourceId { get; set; } - - [ApiMember(Name = "Index", Description = "The attachment stream index", IsRequired = true, DataType = "int", ParameterType = "path", Verb = "GET")] - public int Index { get; set; } - } - - public class AttachmentService : BaseApiService - { - private readonly ILibraryManager _libraryManager; - private readonly IAttachmentExtractor _attachmentExtractor; - - public AttachmentService( - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ILibraryManager libraryManager, - IAttachmentExtractor attachmentExtractor) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _libraryManager = libraryManager; - _attachmentExtractor = attachmentExtractor; - } - - public async Task Get(GetAttachment request) - { - var (attachment, attachmentStream) = await GetAttachment(request).ConfigureAwait(false); - var mime = string.IsNullOrWhiteSpace(attachment.MimeType) ? "application/octet-stream" : attachment.MimeType; - - return ResultFactory.GetResult(Request, attachmentStream, mime); - } - - private Task<(MediaAttachment, Stream)> GetAttachment(GetAttachment request) - { - var item = _libraryManager.GetItemById(request.Id); - - return _attachmentExtractor.GetAttachment(item, - request.MediaSourceId, - request.Index, - CancellationToken.None); - } - } -} diff --git a/MediaBrowser.Api/Devices/DeviceService.cs b/MediaBrowser.Api/Devices/DeviceService.cs deleted file mode 100644 index 7004a2559e..0000000000 --- a/MediaBrowser.Api/Devices/DeviceService.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.IO; -using System.Threading.Tasks; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Devices; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Devices; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Devices -{ - [Route("/Devices", "GET", Summary = "Gets all devices")] - [Authenticated(Roles = "Admin")] - public class GetDevices : DeviceQuery, IReturn> - { - } - - [Route("/Devices/Info", "GET", Summary = "Gets info for a device")] - [Authenticated(Roles = "Admin")] - public class GetDeviceInfo : IReturn - { - [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Devices/Options", "GET", Summary = "Gets options for a device")] - [Authenticated(Roles = "Admin")] - public class GetDeviceOptions : IReturn - { - [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string Id { get; set; } - } - - [Route("/Devices", "DELETE", Summary = "Deletes a device")] - public class DeleteDevice - { - [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string Id { get; set; } - } - - [Route("/Devices/CameraUploads", "GET", Summary = "Gets camera upload history for a device")] - [Authenticated] - public class GetCameraUploads : IReturn - { - [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string DeviceId { get; set; } - } - - [Route("/Devices/CameraUploads", "POST", Summary = "Uploads content")] - [Authenticated] - public class PostCameraUpload : IRequiresRequestStream, IReturnVoid - { - [ApiMember(Name = "DeviceId", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string DeviceId { get; set; } - - [ApiMember(Name = "Album", Description = "Album", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Album { get; set; } - - [ApiMember(Name = "Name", Description = "Name", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Name { get; set; } - - [ApiMember(Name = "Id", Description = "Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Id { get; set; } - - public Stream RequestStream { get; set; } - } - - [Route("/Devices/Options", "POST", Summary = "Updates device options")] - [Authenticated(Roles = "Admin")] - public class PostDeviceOptions : DeviceOptions, IReturnVoid - { - [ApiMember(Name = "Id", Description = "Device Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "DELETE")] - public string Id { get; set; } - } - - public class DeviceService : BaseApiService - { - private readonly IDeviceManager _deviceManager; - private readonly IAuthenticationRepository _authRepo; - private readonly ISessionManager _sessionManager; - - public DeviceService( - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IDeviceManager deviceManager, - IAuthenticationRepository authRepo, - ISessionManager sessionManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _deviceManager = deviceManager; - _authRepo = authRepo; - _sessionManager = sessionManager; - } - - public void Post(PostDeviceOptions request) - { - _deviceManager.UpdateDeviceOptions(request.Id, request); - } - - public object Get(GetDevices request) - { - return ToOptimizedResult(_deviceManager.GetDevices(request)); - } - - public object Get(GetDeviceInfo request) - { - return _deviceManager.GetDevice(request.Id); - } - - public object Get(GetDeviceOptions request) - { - return _deviceManager.GetDeviceOptions(request.Id); - } - - public object Get(GetCameraUploads request) - { - return ToOptimizedResult(_deviceManager.GetCameraUploadHistory(request.DeviceId)); - } - - public void Delete(DeleteDevice request) - { - var sessions = _authRepo.Get(new AuthenticationInfoQuery - { - DeviceId = request.Id - - }).Items; - - foreach (var session in sessions) - { - _sessionManager.Logout(session); - } - } - - public Task Post(PostCameraUpload request) - { - var deviceId = Request.QueryString["DeviceId"]; - var album = Request.QueryString["Album"]; - var id = Request.QueryString["Id"]; - var name = Request.QueryString["Name"]; - var req = Request.Response.HttpContext.Request; - - if (req.HasFormContentType) - { - var file = req.Form.Files.Count == 0 ? null : req.Form.Files[0]; - - return _deviceManager.AcceptCameraUpload(deviceId, file.OpenReadStream(), new LocalFileInfo - { - MimeType = file.ContentType, - Album = album, - Name = name, - Id = id - }); - } - - return _deviceManager.AcceptCameraUpload(deviceId, request.RequestStream, new LocalFileInfo - { - MimeType = Request.ContentType, - Album = album, - Name = name, - Id = id - }); - } - } -} diff --git a/MediaBrowser.Api/MediaBrowser.Api.csproj b/MediaBrowser.Api/MediaBrowser.Api.csproj index 0d62cf8c59..5ca74d4238 100644 --- a/MediaBrowser.Api/MediaBrowser.Api.csproj +++ b/MediaBrowser.Api/MediaBrowser.Api.csproj @@ -9,6 +9,10 @@ + + + + netstandard2.1 false diff --git a/MediaBrowser.Api/PackageService.cs b/MediaBrowser.Api/PackageService.cs deleted file mode 100644 index 444354a992..0000000000 --- a/MediaBrowser.Api/PackageService.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; -using MediaBrowser.Common.Extensions; -using MediaBrowser.Common.Updates; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Services; -using MediaBrowser.Model.Updates; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// - /// Class GetPackage - /// - [Route("/Packages/{Name}", "GET", Summary = "Gets a package, by name or assembly guid")] - [Authenticated] - public class GetPackage : IReturn - { - /// - /// Gets or sets the name. - /// - /// The name. - [ApiMember(Name = "Name", Description = "The name of the package", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] - public string Name { get; set; } - - /// - /// Gets or sets the name. - /// - /// The name. - [ApiMember(Name = "AssemblyGuid", Description = "The guid of the associated assembly", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string AssemblyGuid { get; set; } - } - - /// - /// Class GetPackages - /// - [Route("/Packages", "GET", Summary = "Gets available packages")] - [Authenticated] - public class GetPackages : IReturn - { - } - - /// - /// Class InstallPackage - /// - [Route("/Packages/Installed/{Name}", "POST", Summary = "Installs a package")] - [Authenticated(Roles = "Admin")] - public class InstallPackage : IReturnVoid - { - /// - /// Gets or sets the name. - /// - /// The name. - [ApiMember(Name = "Name", Description = "Package name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] - public string Name { get; set; } - - /// - /// Gets or sets the name. - /// - /// The name. - [ApiMember(Name = "AssemblyGuid", Description = "Guid of the associated assembly", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string AssemblyGuid { get; set; } - - /// - /// Gets or sets the version. - /// - /// The version. - [ApiMember(Name = "Version", Description = "Optional version. Defaults to latest version.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] - public string Version { get; set; } - } - - /// - /// Class CancelPackageInstallation - /// - [Route("/Packages/Installing/{Id}", "DELETE", Summary = "Cancels a package installation")] - [Authenticated(Roles = "Admin")] - public class CancelPackageInstallation : IReturnVoid - { - /// - /// Gets or sets the id. - /// - /// The id. - [ApiMember(Name = "Id", Description = "Installation Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Id { get; set; } - } - - /// - /// Class PackageService - /// - public class PackageService : BaseApiService - { - private readonly IInstallationManager _installationManager; - - public PackageService( - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IInstallationManager installationManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _installationManager = installationManager; - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public object Get(GetPackage request) - { - var packages = _installationManager.GetAvailablePackages().GetAwaiter().GetResult(); - var result = _installationManager.FilterPackages( - packages, - request.Name, - string.IsNullOrEmpty(request.AssemblyGuid) ? default : Guid.Parse(request.AssemblyGuid)).FirstOrDefault(); - - return ToOptimizedResult(result); - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public async Task Get(GetPackages request) - { - IEnumerable packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - - return ToOptimizedResult(packages.ToArray()); - } - - /// - /// Posts the specified request. - /// - /// The request. - /// - public async Task Post(InstallPackage request) - { - var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false); - var package = _installationManager.GetCompatibleVersions( - packages, - request.Name, - string.IsNullOrEmpty(request.AssemblyGuid) ? Guid.Empty : Guid.Parse(request.AssemblyGuid), - string.IsNullOrEmpty(request.Version) ? null : Version.Parse(request.Version)).FirstOrDefault(); - - if (package == null) - { - throw new ResourceNotFoundException( - string.Format( - CultureInfo.InvariantCulture, - "Package not found: {0}", - request.Name)); - } - - await _installationManager.InstallPackage(package); - } - - /// - /// Deletes the specified request. - /// - /// The request. - public void Delete(CancelPackageInstallation request) - { - _installationManager.CancelInstallation(new Guid(request.Id)); - } - } -} diff --git a/MediaBrowser.Api/SearchService.cs b/MediaBrowser.Api/SearchService.cs deleted file mode 100644 index e9d339c6e3..0000000000 --- a/MediaBrowser.Api/SearchService.cs +++ /dev/null @@ -1,333 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Drawing; -using MediaBrowser.Controller.Dto; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Search; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api -{ - /// - /// Class GetSearchHints - /// - [Route("/Search/Hints", "GET", Summary = "Gets search hints based on a search term")] - public class GetSearchHints : IReturn - { - /// - /// Skips over a given number of items within the results. Use for paging. - /// - /// The start index. - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// - /// The maximum number of items to return - /// - /// The limit. - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - /// - /// Gets or sets the user id. - /// - /// The user id. - [ApiMember(Name = "UserId", Description = "Optional. Supply a user id to search within a user's library or omit to search all.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public Guid UserId { get; set; } - - /// - /// Search characters used to find items - /// - /// The index by. - [ApiMember(Name = "SearchTerm", Description = "The search term to filter on", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "GET")] - public string SearchTerm { get; set; } - - - [ApiMember(Name = "IncludePeople", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludePeople { get; set; } - - [ApiMember(Name = "IncludeMedia", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludeMedia { get; set; } - - [ApiMember(Name = "IncludeGenres", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludeGenres { get; set; } - - [ApiMember(Name = "IncludeStudios", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludeStudios { get; set; } - - [ApiMember(Name = "IncludeArtists", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] - public bool IncludeArtists { get; set; } - - [ApiMember(Name = "IncludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string IncludeItemTypes { get; set; } - - [ApiMember(Name = "ExcludeItemTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string ExcludeItemTypes { get; set; } - - [ApiMember(Name = "MediaTypes", Description = "Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimeted.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET", AllowMultiple = true)] - public string MediaTypes { get; set; } - - public string ParentId { get; set; } - - [ApiMember(Name = "IsMovie", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsMovie { get; set; } - - [ApiMember(Name = "IsSeries", Description = "Optional filter for movies.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSeries { get; set; } - - [ApiMember(Name = "IsNews", Description = "Optional filter for news.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsNews { get; set; } - - [ApiMember(Name = "IsKids", Description = "Optional filter for kids.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsKids { get; set; } - - [ApiMember(Name = "IsSports", Description = "Optional filter for sports.", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET,POST")] - public bool? IsSports { get; set; } - - public GetSearchHints() - { - IncludeArtists = true; - IncludeGenres = true; - IncludeMedia = true; - IncludePeople = true; - IncludeStudios = true; - } - } - - /// - /// Class SearchService - /// - [Authenticated] - public class SearchService : BaseApiService - { - /// - /// The _search engine - /// - private readonly ISearchEngine _searchEngine; - private readonly ILibraryManager _libraryManager; - private readonly IDtoService _dtoService; - private readonly IImageProcessor _imageProcessor; - - /// - /// Initializes a new instance of the class. - /// - /// The search engine. - /// The library manager. - /// The dto service. - /// The image processor. - public SearchService( - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ISearchEngine searchEngine, - ILibraryManager libraryManager, - IDtoService dtoService, - IImageProcessor imageProcessor) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _searchEngine = searchEngine; - _libraryManager = libraryManager; - _dtoService = dtoService; - _imageProcessor = imageProcessor; - } - - /// - /// Gets the specified request. - /// - /// The request. - /// System.Object. - public object Get(GetSearchHints request) - { - var result = GetSearchHintsAsync(request); - - return ToOptimizedResult(result); - } - - /// - /// Gets the search hints async. - /// - /// The request. - /// Task{IEnumerable{SearchHintResult}}. - private SearchHintResult GetSearchHintsAsync(GetSearchHints request) - { - var result = _searchEngine.GetSearchHints(new SearchQuery - { - Limit = request.Limit, - SearchTerm = request.SearchTerm, - IncludeArtists = request.IncludeArtists, - IncludeGenres = request.IncludeGenres, - IncludeMedia = request.IncludeMedia, - IncludePeople = request.IncludePeople, - IncludeStudios = request.IncludeStudios, - StartIndex = request.StartIndex, - UserId = request.UserId, - IncludeItemTypes = ApiEntryPoint.Split(request.IncludeItemTypes, ',', true), - ExcludeItemTypes = ApiEntryPoint.Split(request.ExcludeItemTypes, ',', true), - MediaTypes = ApiEntryPoint.Split(request.MediaTypes, ',', true), - ParentId = request.ParentId, - - IsKids = request.IsKids, - IsMovie = request.IsMovie, - IsNews = request.IsNews, - IsSeries = request.IsSeries, - IsSports = request.IsSports - - }); - - return new SearchHintResult - { - TotalRecordCount = result.TotalRecordCount, - - SearchHints = result.Items.Select(GetSearchHintResult).ToArray() - }; - } - - /// - /// Gets the search hint result. - /// - /// The hint info. - /// SearchHintResult. - private SearchHint GetSearchHintResult(SearchHintInfo hintInfo) - { - var item = hintInfo.Item; - - var result = new SearchHint - { - Name = item.Name, - IndexNumber = item.IndexNumber, - ParentIndexNumber = item.ParentIndexNumber, - Id = item.Id, - Type = item.GetClientTypeName(), - MediaType = item.MediaType, - MatchedTerm = hintInfo.MatchedTerm, - RunTimeTicks = item.RunTimeTicks, - ProductionYear = item.ProductionYear, - ChannelId = item.ChannelId, - EndDate = item.EndDate - }; - - // legacy - result.ItemId = result.Id; - - if (item.IsFolder) - { - result.IsFolder = true; - } - - var primaryImageTag = _imageProcessor.GetImageCacheTag(item, ImageType.Primary); - - if (primaryImageTag != null) - { - result.PrimaryImageTag = primaryImageTag; - result.PrimaryImageAspectRatio = _dtoService.GetPrimaryImageAspectRatio(item); - } - - SetThumbImageInfo(result, item); - SetBackdropImageInfo(result, item); - - switch (item) - { - case IHasSeries hasSeries: - result.Series = hasSeries.SeriesName; - break; - case LiveTvProgram program: - result.StartDate = program.StartDate; - break; - case Series series: - if (series.Status.HasValue) - { - result.Status = series.Status.Value.ToString(); - } - - break; - case MusicAlbum album: - result.Artists = album.Artists; - result.AlbumArtist = album.AlbumArtist; - break; - case Audio song: - result.AlbumArtist = song.AlbumArtists.FirstOrDefault(); - result.Artists = song.Artists; - - MusicAlbum musicAlbum = song.AlbumEntity; - - if (musicAlbum != null) - { - result.Album = musicAlbum.Name; - result.AlbumId = musicAlbum.Id; - } - else - { - result.Album = song.Album; - } - - break; - } - - if (!item.ChannelId.Equals(Guid.Empty)) - { - var channel = _libraryManager.GetItemById(item.ChannelId); - result.ChannelName = channel?.Name; - } - - return result; - } - - private void SetThumbImageInfo(SearchHint hint, BaseItem item) - { - var itemWithImage = item.HasImage(ImageType.Thumb) ? item : null; - - if (itemWithImage == null && item is Episode) - { - itemWithImage = GetParentWithImage(item, ImageType.Thumb); - } - - if (itemWithImage == null) - { - itemWithImage = GetParentWithImage(item, ImageType.Thumb); - } - - if (itemWithImage != null) - { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Thumb); - - if (tag != null) - { - hint.ThumbImageTag = tag; - hint.ThumbImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); - } - } - } - - private void SetBackdropImageInfo(SearchHint hint, BaseItem item) - { - var itemWithImage = (item.HasImage(ImageType.Backdrop) ? item : null) - ?? GetParentWithImage(item, ImageType.Backdrop); - - if (itemWithImage != null) - { - var tag = _imageProcessor.GetImageCacheTag(itemWithImage, ImageType.Backdrop); - - if (tag != null) - { - hint.BackdropImageTag = tag; - hint.BackdropImageItemId = itemWithImage.Id.ToString("N", CultureInfo.InvariantCulture); - } - } - } - - private T GetParentWithImage(BaseItem item, ImageType type) - where T : BaseItem - { - return item.GetParents().OfType().FirstOrDefault(i => i.HasImage(type)); - } - } -} diff --git a/MediaBrowser.Api/Sessions/ApiKeyService.cs b/MediaBrowser.Api/Sessions/ApiKeyService.cs deleted file mode 100644 index 5102ce0a7c..0000000000 --- a/MediaBrowser.Api/Sessions/ApiKeyService.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Globalization; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Controller.Security; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.Sessions -{ - [Route("/Auth/Keys", "GET")] - [Authenticated(Roles = "Admin")] - public class GetKeys - { - } - - [Route("/Auth/Keys/{Key}", "DELETE")] - [Authenticated(Roles = "Admin")] - public class RevokeKey - { - [ApiMember(Name = "Key", Description = "Authentication key", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")] - public string Key { get; set; } - } - - [Route("/Auth/Keys", "POST")] - [Authenticated(Roles = "Admin")] - public class CreateKey - { - [ApiMember(Name = "App", Description = "Name of the app using the authentication key", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] - public string App { get; set; } - } - - public class ApiKeyService : BaseApiService - { - private readonly ISessionManager _sessionManager; - - private readonly IAuthenticationRepository _authRepo; - - private readonly IServerApplicationHost _appHost; - - public ApiKeyService( - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - ISessionManager sessionManager, - IServerApplicationHost appHost, - IAuthenticationRepository authRepo) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _sessionManager = sessionManager; - _authRepo = authRepo; - _appHost = appHost; - } - - public void Delete(RevokeKey request) - { - _sessionManager.RevokeToken(request.Key); - } - - public void Post(CreateKey request) - { - _authRepo.Create(new AuthenticationInfo - { - AppName = request.App, - AccessToken = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture), - DateCreated = DateTime.UtcNow, - DeviceId = _appHost.SystemId, - DeviceName = _appHost.FriendlyName, - AppVersion = _appHost.ApplicationVersionString - }); - } - - public object Get(GetKeys request) - { - var result = _authRepo.Get(new AuthenticationInfoQuery - { - HasUser = false - }); - - return result; - } - } -} diff --git a/MediaBrowser.Api/System/ActivityLogService.cs b/MediaBrowser.Api/System/ActivityLogService.cs deleted file mode 100644 index f95fa7ca0b..0000000000 --- a/MediaBrowser.Api/System/ActivityLogService.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Globalization; -using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Activity; -using MediaBrowser.Model.Querying; -using MediaBrowser.Model.Services; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Api.System -{ - [Route("/System/ActivityLog/Entries", "GET", Summary = "Gets activity log entries")] - public class GetActivityLogs : IReturn> - { - /// - /// Skips over a given number of items within the results. Use for paging. - /// - /// The start index. - [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? StartIndex { get; set; } - - /// - /// The maximum number of items to return - /// - /// The limit. - [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] - public int? Limit { get; set; } - - [ApiMember(Name = "MinDate", Description = "Optional. The minimum date. Format = ISO", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")] - public string MinDate { get; set; } - - public bool? HasUserId { get; set; } - } - - [Authenticated(Roles = "Admin")] - public class ActivityLogService : BaseApiService - { - private readonly IActivityManager _activityManager; - - public ActivityLogService( - ILogger logger, - IServerConfigurationManager serverConfigurationManager, - IHttpResultFactory httpResultFactory, - IActivityManager activityManager) - : base(logger, serverConfigurationManager, httpResultFactory) - { - _activityManager = activityManager; - } - - public object Get(GetActivityLogs request) - { - DateTime? minDate = string.IsNullOrWhiteSpace(request.MinDate) ? - (DateTime?)null : - DateTime.Parse(request.MinDate, null, DateTimeStyles.RoundtripKind).ToUniversalTime(); - - var result = _activityManager.GetActivityLogEntries(minDate, request.HasUserId, request.StartIndex, request.Limit); - - return ToOptimizedResult(result); - } - } -} diff --git a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs new file mode 100644 index 0000000000..636ef5372f --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs @@ -0,0 +1,78 @@ +#nullable enable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// + /// Converter for Dictionaries without string key. + /// TODO This can be removed when System.Text.Json supports Dictionaries with non-string keys. + /// + /// Type of key. + /// Type of value. + internal sealed class JsonNonStringKeyDictionaryConverter : JsonConverter> + { + /// + /// Read JSON. + /// + /// The Utf8JsonReader. + /// The type to convert. + /// The json serializer options. + /// Typed dictionary. + /// + public override IDictionary Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var convertedType = typeof(Dictionary<,>).MakeGenericType(typeof(string), typeToConvert.GenericTypeArguments[1]); + var value = JsonSerializer.Deserialize(ref reader, convertedType, options); + var instance = (Dictionary)Activator.CreateInstance( + typeToConvert, + BindingFlags.Instance | BindingFlags.Public, + null, + null, + CultureInfo.CurrentCulture); + var enumerator = (IEnumerator)convertedType.GetMethod("GetEnumerator")!.Invoke(value, null); + var parse = typeof(TKey).GetMethod( + "Parse", + 0, + BindingFlags.Public | BindingFlags.Static, + null, + CallingConventions.Any, + new[] { typeof(string) }, + null); + if (parse == null) + { + throw new NotSupportedException($"{typeof(TKey)} as TKey in IDictionary is not supported."); + } + + while (enumerator.MoveNext()) + { + var element = (KeyValuePair)enumerator.Current; + instance.Add((TKey)parse.Invoke(null, new[] { (object?) element.Key }), element.Value); + } + + return instance; + } + + /// + /// Write dictionary as Json. + /// + /// The Utf8JsonWriter. + /// The dictionary value. + /// The Json serializer options. + public override void Write(Utf8JsonWriter writer, IDictionary value, JsonSerializerOptions options) + { + var convertedDictionary = new Dictionary(value.Count); + foreach (var (k, v) in value) + { + convertedDictionary[k?.ToString()] = v; + } + JsonSerializer.Serialize(writer, convertedDictionary, options); + } + } +} diff --git a/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs new file mode 100644 index 0000000000..d9795a189a --- /dev/null +++ b/MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs @@ -0,0 +1,60 @@ +#nullable enable + +using System; +using System.Collections; +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MediaBrowser.Common.Json.Converters +{ + /// + /// https://github.com/dotnet/runtime/issues/30524#issuecomment-524619972. + /// TODO This can be removed when System.Text.Json supports Dictionaries with non-string keys. + /// + internal sealed class JsonNonStringKeyDictionaryConverterFactory : JsonConverterFactory + { + /// + /// Only convert objects that implement IDictionary and do not have string keys. + /// + /// Type convert. + /// Conversion ability. + public override bool CanConvert(Type typeToConvert) + { + + if (!typeToConvert.IsGenericType) + { + return false; + } + + // Let built in converter handle string keys + if (typeToConvert.GenericTypeArguments[0] == typeof(string)) + { + return false; + } + + // Only support objects that implement IDictionary + return typeToConvert.GetInterface(nameof(IDictionary)) != null; + } + + /// + /// Create converter for generic dictionary type. + /// + /// Type to convert. + /// Json serializer options. + /// JsonConverter for given type. + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var converterType = typeof(JsonNonStringKeyDictionaryConverter<,>) + .MakeGenericType(typeToConvert.GenericTypeArguments[0], typeToConvert.GenericTypeArguments[1]); + var converter = (JsonConverter)Activator.CreateInstance( + converterType, + BindingFlags.Instance | BindingFlags.Public, + null, + null, + CultureInfo.CurrentCulture); + return converter; + } + } +} diff --git a/MediaBrowser.Common/Json/JsonDefaults.cs b/MediaBrowser.Common/Json/JsonDefaults.cs index 4a6ee0a793..f38e2893ec 100644 --- a/MediaBrowser.Common/Json/JsonDefaults.cs +++ b/MediaBrowser.Common/Json/JsonDefaults.cs @@ -12,10 +12,16 @@ namespace MediaBrowser.Common.Json /// /// Gets the default options. /// + /// + /// When changing these options, update + /// Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs + /// -> AddJellyfinApi + /// -> AddJsonOptions + /// /// The default options. public static JsonSerializerOptions GetOptions() { - var options = new JsonSerializerOptions() + var options = new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Disallow, WriteIndented = false @@ -23,8 +29,35 @@ namespace MediaBrowser.Common.Json options.Converters.Add(new JsonGuidConverter()); options.Converters.Add(new JsonStringEnumConverter()); + options.Converters.Add(new JsonNonStringKeyDictionaryConverterFactory()); return options; } + + /// + /// Gets CamelCase json options. + /// + public static JsonSerializerOptions CamelCase + { + get + { + var options = GetOptions(); + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + return options; + } + } + + /// + /// Gets PascalCase json options. + /// + public static JsonSerializerOptions PascalCase + { + get + { + var options = GetOptions(); + options.PropertyNamingPolicy = null; + return options; + } + } } } diff --git a/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs b/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs index 29fb81e32a..9f2743ea18 100644 --- a/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs +++ b/MediaBrowser.Controller/Net/AuthenticatedAttribute.cs @@ -52,6 +52,8 @@ namespace MediaBrowser.Controller.Net return (Roles ?? string.Empty).Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); } + public bool IgnoreLegacyAuth { get; set; } + public bool AllowLocalOnly { get; set; } } @@ -63,5 +65,7 @@ namespace MediaBrowser.Controller.Net bool AllowLocalOnly { get; } string[] GetRoles(); + + bool IgnoreLegacyAuth { get; } } } diff --git a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs index 3b3d03c8b7..437dfa410b 100644 --- a/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs @@ -88,7 +88,9 @@ namespace Jellyfin.Api.Tests.Auth var authenticateResult = await _sut.AuthenticateAsync(); Assert.False(authenticateResult.Succeeded); - Assert.Equal("Invalid user", authenticateResult.Failure.Message); + Assert.True(authenticateResult.None); + // TODO return when legacy API is removed. + // Assert.Equal("Invalid user", authenticateResult.Failure.Message); } [Fact]