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