浏览代码

Merge remote-tracking branch 'upstream/api-migration' into api-swagger-auth

crobibero 5 年之前
父节点
当前提交
2fe65d99f6
共有 32 个文件被更改,包括 1141 次插入950 次删除
  1. 6 0
      Emby.Server.Implementations/HttpServer/Security/AuthService.cs
  2. 9 2
      Jellyfin.Api/Auth/CustomAuthenticationHandler.cs
  3. 1 0
      Jellyfin.Api/BaseJellyfinApiController.cs
  4. 50 0
      Jellyfin.Api/Controllers/ActivityLogController.cs
  5. 97 0
      Jellyfin.Api/Controllers/ApiKeyController.cs
  6. 3 13
      Jellyfin.Api/Controllers/ConfigurationController.cs
  7. 156 0
      Jellyfin.Api/Controllers/DevicesController.cs
  8. 120 0
      Jellyfin.Api/Controllers/PackageController.cs
  9. 268 0
      Jellyfin.Api/Controllers/SearchController.cs
  10. 1 0
      Jellyfin.Api/Controllers/StartupController.cs
  11. 84 0
      Jellyfin.Api/Controllers/VideoAttachmentsController.cs
  12. 29 0
      Jellyfin.Api/Helpers/RequestHelpers.cs
  13. 49 4
      Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
  14. 2 2
      Jellyfin.Server/Formatters/CamelCaseJsonProfileFormatter.cs
  15. 36 0
      Jellyfin.Server/Formatters/CssOutputFormatter.cs
  16. 2 2
      Jellyfin.Server/Formatters/PascalCaseJsonProfileFormatter.cs
  17. 12 2
      Jellyfin.Server/Middleware/ExceptionMiddleware.cs
  18. 0 41
      Jellyfin.Server/Models/JsonOptions.cs
  19. 30 0
      Jellyfin.Server/Models/ServerCorsPolicy.cs
  20. 3 1
      Jellyfin.Server/Startup.cs
  21. 0 63
      MediaBrowser.Api/Attachments/AttachmentService.cs
  22. 0 168
      MediaBrowser.Api/Devices/DeviceService.cs
  23. 4 0
      MediaBrowser.Api/MediaBrowser.Api.csproj
  24. 0 171
      MediaBrowser.Api/PackageService.cs
  25. 0 333
      MediaBrowser.Api/SearchService.cs
  26. 0 85
      MediaBrowser.Api/Sessions/ApiKeyService.cs
  27. 0 61
      MediaBrowser.Api/System/ActivityLogService.cs
  28. 78 0
      MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverter.cs
  29. 60 0
      MediaBrowser.Common/Json/Converters/JsonNonStringKeyDictionaryConverterFactory.cs
  30. 34 1
      MediaBrowser.Common/Json/JsonDefaults.cs
  31. 4 0
      MediaBrowser.Controller/Net/AuthenticatedAttribute.cs
  32. 3 1
      tests/Jellyfin.Api.Tests/Auth/CustomAuthenticationHandlerTests.cs

+ 6 - 0
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;
         }
 

+ 9 - 2
Jellyfin.Api/Auth/CustomAuthenticationHandler.cs

@@ -37,13 +37,20 @@ namespace Jellyfin.Api.Auth
         /// <inheritdoc />
         protected override Task<AuthenticateResult> 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[]

+ 1 - 0
Jellyfin.Api/BaseJellyfinApiController.cs

@@ -1,3 +1,4 @@
+using System;
 using Microsoft.AspNetCore.Mvc;
 
 namespace Jellyfin.Api

+ 50 - 0
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
+{
+    /// <summary>
+    /// Activity log controller.
+    /// </summary>
+    [Route("/System/ActivityLog")]
+    [Authorize(Policy = Policies.RequiresElevation)]
+    public class ActivityLogController : BaseJellyfinApiController
+    {
+        private readonly IActivityManager _activityManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ActivityLogController"/> class.
+        /// </summary>
+        /// <param name="activityManager">Instance of <see cref="IActivityManager"/> interface.</param>
+        public ActivityLogController(IActivityManager activityManager)
+        {
+            _activityManager = activityManager;
+        }
+
+        /// <summary>
+        /// Gets activity log entries.
+        /// </summary>
+        /// <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="minDate">Optional. The minimum date. Format = ISO.</param>
+        /// <param name="hasUserId">Optional. Only returns activities that have a user associated.</param>
+        /// <response code="200">Activity log returned.</response>
+        /// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
+        [HttpGet("Entries")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<ActivityLogEntry>> GetLogEntries(
+            [FromQuery] int? startIndex,
+            [FromQuery] int? limit,
+            [FromQuery] DateTime? minDate,
+            bool? hasUserId)
+        {
+            return _activityManager.GetActivityLogEntries(minDate, hasUserId, startIndex, limit);
+        }
+    }
+}

+ 97 - 0
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
+{
+    /// <summary>
+    /// Authentication controller.
+    /// </summary>
+    [Route("/Auth")]
+    public class ApiKeyController : BaseJellyfinApiController
+    {
+        private readonly ISessionManager _sessionManager;
+        private readonly IServerApplicationHost _appHost;
+        private readonly IAuthenticationRepository _authRepo;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ApiKeyController"/> class.
+        /// </summary>
+        /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+        /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param>
+        /// <param name="authRepo">Instance of <see cref="IAuthenticationRepository"/> interface.</param>
+        public ApiKeyController(
+            ISessionManager sessionManager,
+            IServerApplicationHost appHost,
+            IAuthenticationRepository authRepo)
+        {
+            _sessionManager = sessionManager;
+            _appHost = appHost;
+            _authRepo = authRepo;
+        }
+
+        /// <summary>
+        /// Get all keys.
+        /// </summary>
+        /// <response code="200">Api keys retrieved.</response>
+        /// <returns>A <see cref="QueryResult{AuthenticationInfo}"/> with all keys.</returns>
+        [HttpGet("Keys")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<AuthenticationInfo>> GetKeys()
+        {
+            var result = _authRepo.Get(new AuthenticationInfoQuery
+            {
+                HasUser = false
+            });
+
+            return result;
+        }
+
+        /// <summary>
+        /// Create a new api key.
+        /// </summary>
+        /// <param name="app">Name of the app using the authentication key.</param>
+        /// <response code="204">Api key created.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [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();
+        }
+
+        /// <summary>
+        /// Remove an api key.
+        /// </summary>
+        /// <param name="key">The access token to delete.</param>
+        /// <response code="204">Api key deleted.</response>
+        /// <returns>A <see cref="NoContentResult"/>.</returns>
+        [HttpDelete("Keys/{key}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status204NoContent)]
+        public ActionResult RevokeKey([FromRoute] string key)
+        {
+            _sessionManager.RevokeToken(key);
+            return NoContent();
+        }
+    }
+}

+ 3 - 13
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;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="ConfigurationController"/> class.
         /// </summary>
         /// <param name="configurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
         /// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
-        /// <param name="jsonSerializer">Instance of the <see cref="IJsonSerializer"/> interface.</param>
         public ConfigurationController(
             IServerConfigurationManager configurationManager,
-            IMediaEncoder mediaEncoder,
-            IJsonSerializer jsonSerializer)
+            IMediaEncoder mediaEncoder)
         {
             _configurationManager = configurationManager;
             _mediaEncoder = mediaEncoder;
-            _jsonSerializer = jsonSerializer;
         }
 
         /// <summary>
@@ -93,13 +89,7 @@ namespace Jellyfin.Api.Controllers
         public async Task<ActionResult> 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();
         }

+ 156 - 0
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
+{
+    /// <summary>
+    /// Devices Controller.
+    /// </summary>
+    [Authorize]
+    public class DevicesController : BaseJellyfinApiController
+    {
+        private readonly IDeviceManager _deviceManager;
+        private readonly IAuthenticationRepository _authenticationRepository;
+        private readonly ISessionManager _sessionManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="DevicesController"/> class.
+        /// </summary>
+        /// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
+        /// <param name="authenticationRepository">Instance of <see cref="IAuthenticationRepository"/> interface.</param>
+        /// <param name="sessionManager">Instance of <see cref="ISessionManager"/> interface.</param>
+        public DevicesController(
+            IDeviceManager deviceManager,
+            IAuthenticationRepository authenticationRepository,
+            ISessionManager sessionManager)
+        {
+            _deviceManager = deviceManager;
+            _authenticationRepository = authenticationRepository;
+            _sessionManager = sessionManager;
+        }
+
+        /// <summary>
+        /// Get Devices.
+        /// </summary>
+        /// <param name="supportsSync">Gets or sets a value indicating whether [supports synchronize].</param>
+        /// <param name="userId">Gets or sets the user identifier.</param>
+        /// <response code="200">Devices retrieved.</response>
+        /// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
+        [HttpGet]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] bool? supportsSync, [FromQuery] Guid? userId)
+        {
+            var deviceQuery = new DeviceQuery { SupportsSync = supportsSync, UserId = userId ?? Guid.Empty };
+            return _deviceManager.GetDevices(deviceQuery);
+        }
+
+        /// <summary>
+        /// Get info for a device.
+        /// </summary>
+        /// <param name="id">Device Id.</param>
+        /// <response code="200">Device info retrieved.</response>
+        /// <response code="404">Device not found.</response>
+        /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        [HttpGet("Info")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, BindRequired] string id)
+        {
+            var deviceInfo = _deviceManager.GetDevice(id);
+            if (deviceInfo == null)
+            {
+                return NotFound();
+            }
+
+            return deviceInfo;
+        }
+
+        /// <summary>
+        /// Get options for a device.
+        /// </summary>
+        /// <param name="id">Device Id.</param>
+        /// <response code="200">Device options retrieved.</response>
+        /// <response code="404">Device not found.</response>
+        /// <returns>An <see cref="OkResult"/> containing the device info on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        [HttpGet("Options")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, BindRequired] string id)
+        {
+            var deviceInfo = _deviceManager.GetDeviceOptions(id);
+            if (deviceInfo == null)
+            {
+                return NotFound();
+            }
+
+            return deviceInfo;
+        }
+
+        /// <summary>
+        /// Update device options.
+        /// </summary>
+        /// <param name="id">Device Id.</param>
+        /// <param name="deviceOptions">Device Options.</param>
+        /// <response code="200">Device options updated.</response>
+        /// <response code="404">Device not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        [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();
+        }
+
+        /// <summary>
+        /// Deletes a device.
+        /// </summary>
+        /// <param name="id">Device Id.</param>
+        /// <response code="200">Device deleted.</response>
+        /// <response code="404">Device not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the device could not be found.</returns>
+        [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();
+        }
+    }
+}

+ 120 - 0
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
+{
+    /// <summary>
+    /// Package Controller.
+    /// </summary>
+    [Route("Packages")]
+    [Authorize]
+    public class PackageController : BaseJellyfinApiController
+    {
+        private readonly IInstallationManager _installationManager;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="PackageController"/> class.
+        /// </summary>
+        /// <param name="installationManager">Instance of <see cref="IInstallationManager"/>Installation Manager.</param>
+        public PackageController(IInstallationManager installationManager)
+        {
+            _installationManager = installationManager;
+        }
+
+        /// <summary>
+        /// Gets a package by name or assembly GUID.
+        /// </summary>
+        /// <param name="name">The name of the package.</param>
+        /// <param name="assemblyGuid">The GUID of the associated assembly.</param>
+        /// <returns>A <see cref="PackageInfo"/> containing package information.</returns>
+        [HttpGet("/{Name}")]
+        [ProducesResponseType(typeof(PackageInfo), StatusCodes.Status200OK)]
+        public async Task<ActionResult<PackageInfo>> 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;
+        }
+
+        /// <summary>
+        /// Gets available packages.
+        /// </summary>
+        /// <returns>An <see cref="PackageInfo"/> containing available packages information.</returns>
+        [HttpGet]
+        [ProducesResponseType(typeof(PackageInfo[]), StatusCodes.Status200OK)]
+        public async Task<IEnumerable<PackageInfo>> GetPackages()
+        {
+            IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
+
+            return packages;
+        }
+
+        /// <summary>
+        /// Installs a package.
+        /// </summary>
+        /// <param name="name">Package name.</param>
+        /// <param name="assemblyGuid">GUID of the associated assembly.</param>
+        /// <param name="version">Optional version. Defaults to latest version.</param>
+        /// <response code="200">Package found.</response>
+        /// <response code="404">Package not found.</response>
+        /// <returns>An <see cref="OkResult"/> on success, or a <see cref="NotFoundResult"/> if the package could not be found.</returns>
+        [HttpPost("/Installed/{Name}")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        public async Task<ActionResult> 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();
+        }
+
+        /// <summary>
+        /// Cancels a package installation.
+        /// </summary>
+        /// <param name="id">Installation Id.</param>
+        /// <response code="200">Installation cancelled.</response>
+        /// <returns>An <see cref="OkResult"/> on successfully cancelling a package installation.</returns>
+        [HttpDelete("/Installing/{id}")]
+        [Authorize(Policy = Policies.RequiresElevation)]
+        public IActionResult CancelPackageInstallation(
+            [FromRoute] [Required] string id)
+        {
+            _installationManager.CancelInstallation(new Guid(id));
+
+            return Ok();
+        }
+    }
+}

+ 268 - 0
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
+{
+    /// <summary>
+    /// Search controller.
+    /// </summary>
+    [Route("/Search/Hints")]
+    [Authorize]
+    public class SearchController : BaseJellyfinApiController
+    {
+        private readonly ISearchEngine _searchEngine;
+        private readonly ILibraryManager _libraryManager;
+        private readonly IDtoService _dtoService;
+        private readonly IImageProcessor _imageProcessor;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SearchController"/> class.
+        /// </summary>
+        /// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param>
+        /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
+        /// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
+        public SearchController(
+            ISearchEngine searchEngine,
+            ILibraryManager libraryManager,
+            IDtoService dtoService,
+            IImageProcessor imageProcessor)
+        {
+            _searchEngine = searchEngine;
+            _libraryManager = libraryManager;
+            _dtoService = dtoService;
+            _imageProcessor = imageProcessor;
+        }
+
+        /// <summary>
+        /// Gets the search hint result.
+        /// </summary>
+        /// <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="userId">Optional. Supply a user id to search within a user's library or omit to search all.</param>
+        /// <param name="searchTerm">The search term to filter on.</param>
+        /// <param name="includeItemTypes">If specified, only results with the specified item types are returned. This allows multiple, comma delimeted.</param>
+        /// <param name="excludeItemTypes">If specified, results with these item types are filtered out. This allows multiple, comma delimeted.</param>
+        /// <param name="mediaTypes">If specified, only results with the specified media types are returned. This allows multiple, comma delimeted.</param>
+        /// <param name="parentId">If specified, only children of the parent are returned.</param>
+        /// <param name="isMovie">Optional filter for movies.</param>
+        /// <param name="isSeries">Optional filter for series.</param>
+        /// <param name="isNews">Optional filter for news.</param>
+        /// <param name="isKids">Optional filter for kids.</param>
+        /// <param name="isSports">Optional filter for sports.</param>
+        /// <param name="includePeople">Optional filter whether to include people.</param>
+        /// <param name="includeMedia">Optional filter whether to include media.</param>
+        /// <param name="includeGenres">Optional filter whether to include genres.</param>
+        /// <param name="includeStudios">Optional filter whether to include studios.</param>
+        /// <param name="includeArtists">Optional filter whether to include artists.</param>
+        /// <response code="200">Search hint returned.</response>
+        /// <returns>An <see cref="SearchHintResult"/> with the results of the search.</returns>
+        [HttpGet]
+        [Description("Gets search hints based on a search term")]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        public ActionResult<SearchHintResult> 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()
+            };
+        }
+
+        /// <summary>
+        /// Gets the search hint result.
+        /// </summary>
+        /// <param name="hintInfo">The hint info.</param>
+        /// <returns>SearchHintResult.</returns>
+        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<Series>(item, ImageType.Thumb);
+            }
+
+            if (itemWithImage == null)
+            {
+                itemWithImage = GetParentWithImage<BaseItem>(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<BaseItem>(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<T>(BaseItem item, ImageType type)
+            where T : BaseItem
+        {
+            return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type));
+        }
+    }
+}

+ 1 - 0
Jellyfin.Api/Controllers/StartupController.cs

@@ -109,6 +109,7 @@ namespace Jellyfin.Api.Controllers
         /// <response code="200">Initial user retrieved.</response>
         /// <returns>The first user.</returns>
         [HttpGet("User")]
+        [HttpGet("FirstUser")]
         [ProducesResponseType(StatusCodes.Status200OK)]
         public ActionResult<StartupUserDto> GetFirstUser()
         {

+ 84 - 0
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
+{
+    /// <summary>
+    /// Attachments controller.
+    /// </summary>
+    [Route("Videos")]
+    [Authorize]
+    public class VideoAttachmentsController : BaseJellyfinApiController
+    {
+        private readonly ILibraryManager _libraryManager;
+        private readonly IAttachmentExtractor _attachmentExtractor;
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="VideoAttachmentsController"/> class.
+        /// </summary>
+        /// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
+        /// <param name="attachmentExtractor">Instance of the <see cref="IAttachmentExtractor"/> interface.</param>
+        public VideoAttachmentsController(
+            ILibraryManager libraryManager,
+            IAttachmentExtractor attachmentExtractor)
+        {
+            _libraryManager = libraryManager;
+            _attachmentExtractor = attachmentExtractor;
+        }
+
+        /// <summary>
+        /// Get video attachment.
+        /// </summary>
+        /// <param name="videoId">Video ID.</param>
+        /// <param name="mediaSourceId">Media Source ID.</param>
+        /// <param name="index">Attachment Index.</param>
+        /// <response code="200">Attachment retrieved.</response>
+        /// <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)]
+        [ProducesResponseType(StatusCodes.Status200OK)]
+        [ProducesResponseType(StatusCodes.Status404NotFound)]
+        public async Task<ActionResult<FileStreamResult>> 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);
+            }
+        }
+    }
+}

+ 29 - 0
Jellyfin.Api/Helpers/RequestHelpers.cs

@@ -0,0 +1,29 @@
+using System;
+
+namespace Jellyfin.Api.Helpers
+{
+    /// <summary>
+    /// Request Extensions.
+    /// </summary>
+    public static class RequestHelpers
+    {
+        /// <summary>
+        /// Splits a string at a separating character into an array of substrings.
+        /// </summary>
+        /// <param name="value">The string to split.</param>
+        /// <param name="separator">The char that separates the substrings.</param>
+        /// <param name="removeEmpty">Option to remove empty substrings from the array.</param>
+        /// <returns>An array of the substrings.</returns>
+        internal static string[] Split(string value, char separator, bool removeEmpty)
+        {
+            if (string.IsNullOrWhiteSpace(value))
+            {
+                return Array.Empty<string>();
+            }
+
+            return removeEmpty
+                ? value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries)
+                : value.Split(separator);
+        }
+    }
+}

+ 49 - 4
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
         /// <returns>The MVC builder.</returns>
         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<string>() }
                 });
+                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<Dictionary<ImageType, string>>(() =>
+                new OpenApiSchema
+                {
+                    Type = "object",
+                    Properties = typeof(ImageType).GetEnumNames().ToDictionary(
+                        name => name,
+                        name => new OpenApiSchema
+                        {
+                            Type = "string",
+                            Format = "string"
+                        })
+                });
+        }
     }
 }

+ 2 - 2
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
         /// <summary>
         /// Initializes a new instance of the <see cref="CamelCaseJsonProfileFormatter"/> class.
         /// </summary>
-        public CamelCaseJsonProfileFormatter() : base(JsonOptions.CamelCase)
+        public CamelCaseJsonProfileFormatter() : base(JsonDefaults.CamelCase)
         {
             SupportedMediaTypes.Clear();
             SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json;profile=\"CamelCase\""));

+ 36 - 0
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
+{
+    /// <summary>
+    /// Css output formatter.
+    /// </summary>
+    public class CssOutputFormatter : TextOutputFormatter
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="CssOutputFormatter"/> class.
+        /// </summary>
+        public CssOutputFormatter()
+        {
+            SupportedMediaTypes.Add("text/css");
+
+            SupportedEncodings.Add(Encoding.UTF8);
+            SupportedEncodings.Add(Encoding.Unicode);
+        }
+
+        /// <summary>
+        /// Write context object to stream.
+        /// </summary>
+        /// <param name="context">Writer context.</param>
+        /// <param name="selectedEncoding">Unused. Writer encoding.</param>
+        /// <returns>Write stream task.</returns>
+        public override Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
+        {
+            return context.HttpContext.Response.WriteAsync(context.Object?.ToString());
+        }
+    }
+}

+ 2 - 2
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
         /// <summary>
         /// Initializes a new instance of the <see cref="PascalCaseJsonProfileFormatter"/> class.
         /// </summary>
-        public PascalCaseJsonProfileFormatter() : base(JsonOptions.PascalCase)
+        public PascalCaseJsonProfileFormatter() : base(JsonDefaults.PascalCase)
         {
             SupportedMediaTypes.Clear();
             // Add application/json for default formatter

+ 12 - 2
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<ExceptionMiddleware> _logger;
         private readonly IServerConfigurationManager _configuration;
+        private readonly IWebHostEnvironment _hostEnvironment;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="ExceptionMiddleware"/> class.
@@ -27,14 +30,17 @@ namespace Jellyfin.Server.Middleware
         /// <param name="next">Next request delegate.</param>
         /// <param name="logger">Instance of the <see cref="ILogger{ExceptionMiddleware}"/> interface.</param>
         /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
+        /// <param name="hostEnvironment">Instance of the <see cref="IWebHostEnvironment"/> interface.</param>
         public ExceptionMiddleware(
             RequestDelegate next,
             ILogger<ExceptionMiddleware> logger,
-            IServerConfigurationManager serverConfigurationManager)
+            IServerConfigurationManager serverConfigurationManager,
+            IWebHostEnvironment hostEnvironment)
         {
             _next = next;
             _logger = logger;
             _configuration = serverConfigurationManager;
+            _hostEnvironment = hostEnvironment;
         }
 
         /// <summary>
@@ -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);
             }
         }

+ 0 - 41
Jellyfin.Server/Models/JsonOptions.cs

@@ -1,41 +0,0 @@
-using System.Text.Json;
-
-namespace Jellyfin.Server.Models
-{
-    /// <summary>
-    /// Json Options.
-    /// </summary>
-    public static class JsonOptions
-    {
-        /// <summary>
-        /// Gets CamelCase json options.
-        /// </summary>
-        public static JsonSerializerOptions CamelCase
-        {
-            get
-            {
-                var options = DefaultJsonOptions;
-                options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
-                return options;
-            }
-        }
-
-        /// <summary>
-        /// Gets PascalCase json options.
-        /// </summary>
-        public static JsonSerializerOptions PascalCase
-        {
-            get
-            {
-                var options = DefaultJsonOptions;
-                options.PropertyNamingPolicy = null;
-                return options;
-            }
-        }
-
-        /// <summary>
-        /// Gets base Json Serializer Options.
-        /// </summary>
-        private static JsonSerializerOptions DefaultJsonOptions => new JsonSerializerOptions();
-    }
-}

+ 30 - 0
Jellyfin.Server/Models/ServerCorsPolicy.cs

@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Cors.Infrastructure;
+
+namespace Jellyfin.Server.Models
+{
+    /// <summary>
+    /// Server Cors Policy.
+    /// </summary>
+    public static class ServerCorsPolicy
+    {
+        /// <summary>
+        /// Default policy name.
+        /// </summary>
+        public const string DefaultPolicyName = "DefaultCorsPolicy";
+
+        /// <summary>
+        /// Default Policy. Allow Everything.
+        /// </summary>
+        public static readonly CorsPolicy DefaultPolicy = new CorsPolicy
+        {
+            // Allow any origin
+            Origins = { "*" },
+
+            // Allow any method
+            Methods = { "*" },
+
+            // Allow any header
+            Headers = { "*" }
+        };
+    }
+}

+ 3 - 1
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<WebSocketMiddleware>();
             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 =>
             {

+ 0 - 63
MediaBrowser.Api/Attachments/AttachmentService.cs

@@ -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<AttachmentService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            ILibraryManager libraryManager,
-            IAttachmentExtractor attachmentExtractor)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _libraryManager = libraryManager;
-            _attachmentExtractor = attachmentExtractor;
-        }
-
-        public async Task<object> 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);
-        }
-    }
-}

+ 0 - 168
MediaBrowser.Api/Devices/DeviceService.cs

@@ -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<QueryResult<DeviceInfo>>
-    {
-    }
-
-    [Route("/Devices/Info", "GET", Summary = "Gets info for a device")]
-    [Authenticated(Roles = "Admin")]
-    public class GetDeviceInfo : IReturn<DeviceInfo>
-    {
-        [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<DeviceOptions>
-    {
-        [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<ContentUploadHistory>
-    {
-        [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<DeviceService> 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
-            });
-        }
-    }
-}

+ 4 - 0
MediaBrowser.Api/MediaBrowser.Api.csproj

@@ -9,6 +9,10 @@
     <Compile Include="..\SharedVersion.cs" />
   </ItemGroup>
 
+  <ItemGroup>
+    <Folder Include="Attachments" />
+  </ItemGroup>
+
   <PropertyGroup>
     <TargetFramework>netstandard2.1</TargetFramework>
     <GenerateAssemblyInfo>false</GenerateAssemblyInfo>

+ 0 - 171
MediaBrowser.Api/PackageService.cs

@@ -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
-{
-    /// <summary>
-    /// Class GetPackage
-    /// </summary>
-    [Route("/Packages/{Name}", "GET", Summary = "Gets a package, by name or assembly guid")]
-    [Authenticated]
-    public class GetPackage : IReturn<PackageInfo>
-    {
-        /// <summary>
-        /// Gets or sets the name.
-        /// </summary>
-        /// <value>The name.</value>
-        [ApiMember(Name = "Name", Description = "The name of the package", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")]
-        public string Name { get; set; }
-
-        /// <summary>
-        /// Gets or sets the name.
-        /// </summary>
-        /// <value>The name.</value>
-        [ApiMember(Name = "AssemblyGuid", Description = "The guid of the associated assembly", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "GET")]
-        public string AssemblyGuid { get; set; }
-    }
-
-    /// <summary>
-    /// Class GetPackages
-    /// </summary>
-    [Route("/Packages", "GET", Summary = "Gets available packages")]
-    [Authenticated]
-    public class GetPackages : IReturn<PackageInfo[]>
-    {
-    }
-
-    /// <summary>
-    /// Class InstallPackage
-    /// </summary>
-    [Route("/Packages/Installed/{Name}", "POST", Summary = "Installs a package")]
-    [Authenticated(Roles = "Admin")]
-    public class InstallPackage : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the name.
-        /// </summary>
-        /// <value>The name.</value>
-        [ApiMember(Name = "Name", Description = "Package name", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
-        public string Name { get; set; }
-
-        /// <summary>
-        /// Gets or sets the name.
-        /// </summary>
-        /// <value>The name.</value>
-        [ApiMember(Name = "AssemblyGuid", Description = "Guid of the associated assembly", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string AssemblyGuid { get; set; }
-
-        /// <summary>
-        /// Gets or sets the version.
-        /// </summary>
-        /// <value>The version.</value>
-        [ApiMember(Name = "Version", Description = "Optional version. Defaults to latest version.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
-        public string Version { get; set; }
-    }
-
-    /// <summary>
-    /// Class CancelPackageInstallation
-    /// </summary>
-    [Route("/Packages/Installing/{Id}", "DELETE", Summary = "Cancels a package installation")]
-    [Authenticated(Roles = "Admin")]
-    public class CancelPackageInstallation : IReturnVoid
-    {
-        /// <summary>
-        /// Gets or sets the id.
-        /// </summary>
-        /// <value>The id.</value>
-        [ApiMember(Name = "Id", Description = "Installation Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "DELETE")]
-        public string Id { get; set; }
-    }
-
-    /// <summary>
-    /// Class PackageService
-    /// </summary>
-    public class PackageService : BaseApiService
-    {
-        private readonly IInstallationManager _installationManager;
-
-        public PackageService(
-            ILogger<PackageService> logger,
-            IServerConfigurationManager serverConfigurationManager,
-            IHttpResultFactory httpResultFactory,
-            IInstallationManager installationManager)
-            : base(logger, serverConfigurationManager, httpResultFactory)
-        {
-            _installationManager = installationManager;
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        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);
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public async Task<object> Get(GetPackages request)
-        {
-            IEnumerable<PackageInfo> packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
-
-            return ToOptimizedResult(packages.ToArray());
-        }
-
-        /// <summary>
-        /// Posts the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <exception cref="ResourceNotFoundException"></exception>
-        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);
-        }
-
-        /// <summary>
-        /// Deletes the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        public void Delete(CancelPackageInstallation request)
-        {
-            _installationManager.CancelInstallation(new Guid(request.Id));
-        }
-    }
-}

+ 0 - 333
MediaBrowser.Api/SearchService.cs

@@ -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
-{
-    /// <summary>
-    /// Class GetSearchHints
-    /// </summary>
-    [Route("/Search/Hints", "GET", Summary = "Gets search hints based on a search term")]
-    public class GetSearchHints : IReturn<SearchHintResult>
-    {
-        /// <summary>
-        /// Skips over a given number of items within the results. Use for paging.
-        /// </summary>
-        /// <value>The start index.</value>
-        [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; }
-
-        /// <summary>
-        /// The maximum number of items to return
-        /// </summary>
-        /// <value>The limit.</value>
-        [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; }
-
-        /// <summary>
-        /// Gets or sets the user id.
-        /// </summary>
-        /// <value>The user id.</value>
-        [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; }
-
-        /// <summary>
-        /// Search characters used to find items
-        /// </summary>
-        /// <value>The index by.</value>
-        [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;
-        }
-    }
-
-    /// <summary>
-    /// Class SearchService
-    /// </summary>
-    [Authenticated]
-    public class SearchService : BaseApiService
-    {
-        /// <summary>
-        /// The _search engine
-        /// </summary>
-        private readonly ISearchEngine _searchEngine;
-        private readonly ILibraryManager _libraryManager;
-        private readonly IDtoService _dtoService;
-        private readonly IImageProcessor _imageProcessor;
-
-        /// <summary>
-        /// Initializes a new instance of the <see cref="SearchService" /> class.
-        /// </summary>
-        /// <param name="searchEngine">The search engine.</param>
-        /// <param name="libraryManager">The library manager.</param>
-        /// <param name="dtoService">The dto service.</param>
-        /// <param name="imageProcessor">The image processor.</param>
-        public SearchService(
-            ILogger<SearchService> 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;
-        }
-
-        /// <summary>
-        /// Gets the specified request.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>System.Object.</returns>
-        public object Get(GetSearchHints request)
-        {
-            var result = GetSearchHintsAsync(request);
-
-            return ToOptimizedResult(result);
-        }
-
-        /// <summary>
-        /// Gets the search hints async.
-        /// </summary>
-        /// <param name="request">The request.</param>
-        /// <returns>Task{IEnumerable{SearchHintResult}}.</returns>
-        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()
-            };
-        }
-
-        /// <summary>
-        /// Gets the search hint result.
-        /// </summary>
-        /// <param name="hintInfo">The hint info.</param>
-        /// <returns>SearchHintResult.</returns>
-        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<Series>(item, ImageType.Thumb);
-            }
-
-            if (itemWithImage == null)
-            {
-                itemWithImage = GetParentWithImage<BaseItem>(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<BaseItem>(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<T>(BaseItem item, ImageType type)
-            where T : BaseItem
-        {
-            return item.GetParents().OfType<T>().FirstOrDefault(i => i.HasImage(type));
-        }
-    }
-}

+ 0 - 85
MediaBrowser.Api/Sessions/ApiKeyService.cs

@@ -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<ApiKeyService> 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;
-        }
-    }
-}

+ 0 - 61
MediaBrowser.Api/System/ActivityLogService.cs

@@ -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<QueryResult<ActivityLogEntry>>
-    {
-        /// <summary>
-        /// Skips over a given number of items within the results. Use for paging.
-        /// </summary>
-        /// <value>The start index.</value>
-        [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; }
-
-        /// <summary>
-        /// The maximum number of items to return
-        /// </summary>
-        /// <value>The limit.</value>
-        [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<ActivityLogService> 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);
-        }
-    }
-}

+ 78 - 0
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
+{
+    /// <summary>
+    /// Converter for Dictionaries without string key.
+    /// TODO This can be removed when System.Text.Json supports Dictionaries with non-string keys.
+    /// </summary>
+    /// <typeparam name="TKey">Type of key.</typeparam>
+    /// <typeparam name="TValue">Type of value.</typeparam>
+    internal sealed class JsonNonStringKeyDictionaryConverter<TKey, TValue> : JsonConverter<IDictionary<TKey, TValue>>
+    {
+        /// <summary>
+        /// Read JSON.
+        /// </summary>
+        /// <param name="reader">The Utf8JsonReader.</param>
+        /// <param name="typeToConvert">The type to convert.</param>
+        /// <param name="options">The json serializer options.</param>
+        /// <returns>Typed dictionary.</returns>
+        /// <exception cref="NotSupportedException"></exception>
+        public override IDictionary<TKey, TValue> 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<TKey, TValue>)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<TKey, TValue> is not supported.");
+            }
+            
+            while (enumerator.MoveNext())
+            {
+                var element = (KeyValuePair<string?, TValue>)enumerator.Current;
+                instance.Add((TKey)parse.Invoke(null, new[] { (object?) element.Key }), element.Value);
+            }
+            
+            return instance;
+        }
+
+        /// <summary>
+        /// Write dictionary as Json.
+        /// </summary>
+        /// <param name="writer">The Utf8JsonWriter.</param>
+        /// <param name="value">The dictionary value.</param>
+        /// <param name="options">The Json serializer options.</param>
+        public override void Write(Utf8JsonWriter writer, IDictionary<TKey, TValue> value, JsonSerializerOptions options)
+        {
+            var convertedDictionary = new Dictionary<string?, TValue>(value.Count);
+            foreach (var (k, v) in value)
+            {
+                convertedDictionary[k?.ToString()] = v;
+            }
+            JsonSerializer.Serialize(writer, convertedDictionary, options);
+        }
+    }
+}

+ 60 - 0
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
+{
+    /// <summary>
+    /// https://github.com/dotnet/runtime/issues/30524#issuecomment-524619972.
+    /// TODO This can be removed when System.Text.Json supports Dictionaries with non-string keys.
+    /// </summary>
+    internal sealed class JsonNonStringKeyDictionaryConverterFactory : JsonConverterFactory
+    {
+        /// <summary>
+        /// Only convert objects that implement IDictionary and do not have string keys.
+        /// </summary>
+        /// <param name="typeToConvert">Type convert.</param>
+        /// <returns>Conversion ability.</returns>
+        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;
+        }
+
+        /// <summary>
+        /// Create converter for generic dictionary type.
+        /// </summary>
+        /// <param name="typeToConvert">Type to convert.</param>
+        /// <param name="options">Json serializer options.</param>
+        /// <returns>JsonConverter for given type.</returns>
+        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;
+        }
+    }
+}

+ 34 - 1
MediaBrowser.Common/Json/JsonDefaults.cs

@@ -12,10 +12,16 @@ namespace MediaBrowser.Common.Json
         /// <summary>
         /// Gets the default <see cref="JsonSerializerOptions" /> options.
         /// </summary>
+        /// <remarks>
+        /// When changing these options, update
+        ///     Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs
+        ///         -> AddJellyfinApi
+        ///             -> AddJsonOptions
+        /// </remarks>
         /// <returns>The default <see cref="JsonSerializerOptions" /> options.</returns>
         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;
         }
+        
+        /// <summary>
+        /// Gets CamelCase json options.
+        /// </summary>
+        public static JsonSerializerOptions CamelCase
+        {
+            get
+            {
+                var options = GetOptions();
+                options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+                return options;
+            }
+        }
+
+        /// <summary>
+        /// Gets PascalCase json options.
+        /// </summary>
+        public static JsonSerializerOptions PascalCase
+        {
+            get
+            {
+                var options = GetOptions();
+                options.PropertyNamingPolicy = null;
+                return options;
+            }
+        }
     }
 }

+ 4 - 0
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; }
     }
 }

+ 3 - 1
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]